From a12962d24c78c5795ee9e845ab5e6e2c1269afa7 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Fri, 6 Feb 2026 10:18:53 -0500 Subject: [PATCH 01/13] [CL-1039] Set code ownership for global style files (#18786) --- .github/CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) 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 From eb402a7ee8d02b8ee3d88f9a6bda455758137eb3 Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Fri, 6 Feb 2026 10:42:16 -0500 Subject: [PATCH 02/13] [PM-30881] Add lock icon to browser Sends list for protected Sends (#18635) * [PM-30881] Add lock icon to browser Sends list for protected Sends * Trigger AI PR review * [PM-30881] Add missing i18n key to browser file --- apps/browser/src/_locales/en/messages.json | 3 +++ .../send-list-items-container.component.html | 26 ++++++++++++------- .../send-list-items-container.component.ts | 2 ++ 3 files changed, 21 insertions(+), 10 deletions(-) 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/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. */ From 9bdfc68aa2667e70b7d0f4214b43b8cd76526d20 Mon Sep 17 00:00:00 2001 From: Will Martin Date: Fri, 6 Feb 2026 10:43:52 -0500 Subject: [PATCH 03/13] [CL-1034] tooltip should only show on focus-visible (#18767) --- .../src/tooltip/tooltip.directive.ts | 18 ++++++++++++++++-- libs/components/src/tooltip/tooltip.spec.ts | 13 +++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) 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); })); From 6b071481e236deac7265d6afef262db15895a1cc Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Feb 2026 10:15:07 -0600 Subject: [PATCH 04/13] pm-31420 Add download button to export Access Intelligence table into csv report (#18802) * pm-31420 add download button feature to new applications tab for access intelligence feature * PM-31420 fixing unit tests * pm-31420 adding types * pm-31420 fixing types and merging in main --- .../applications.component.html | 9 + .../applications.component.spec.ts | 242 ++++++++++++++++++ .../applications.component.ts | 41 +++ 3 files changed, 292 insertions(+) create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.spec.ts 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..765985d43b3 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,6 +33,15 @@ {{ "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..4f8b1eb34f2 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; @@ -225,4 +231,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); + } + }; } From 256fe6305f6188a38fd3c33c5f3022f4c06ffa20 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:32:41 -0600 Subject: [PATCH 05/13] restore archived item from trash to archive (#18795) --- apps/cli/src/commands/restore.command.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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); From bf13194b9cde3dbad47866cfe4fc752935e3e52a Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Fri, 6 Feb 2026 12:10:16 -0500 Subject: [PATCH 06/13] [PM-31668] Race condition in cipher cache clearing causes stale failed decryption state after leaving organization (#18751) * Refactored the search index to index with the cipherlistview * Fixed comment * clear encrypted cipher state to prevent stale emissions during sync --- .../src/platform/sync/default-sync.service.ts | 2 + .../src/vault/abstractions/search.service.ts | 3 +- .../src/vault/services/cipher.service.ts | 16 +- .../src/vault/services/search.service.ts | 120 ++++++----- .../utils/cipher-view-like-utils.spec.ts | 194 ++++++++++++++++++ .../src/vault/utils/cipher-view-like-utils.ts | 66 ++++++ 6 files changed, 340 insertions(+), 61 deletions(-) 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.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/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; + }; } /** From 6a01e7436c9b54b83525fe59a6ade134ccb50d83 Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:04:48 -0800 Subject: [PATCH 07/13] [PM-30543] Add select all checkbox to Access Intelligance Applications table (#18682) Adds a "Select all" checkbox to the table in the Access Intelligence applications tab. This allows users to quickly select or deselect all applications currently showing in the table for marking as critical apps. --- apps/web/src/locales/en/messages.json | 3 + .../applications.component.html | 1 - .../applications.component.ts | 9 + ...pp-table-row-scrollable-m11.component.html | 75 ++++---- ...table-row-scrollable-m11.component.spec.ts | 181 ++++++++++++++++++ .../app-table-row-scrollable-m11.component.ts | 72 ++++--- 6 files changed, 270 insertions(+), 71 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.spec.ts diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 89b3b3ac5c6..4d69bf45311 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" }, 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 765985d43b3..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 @@ -46,7 +46,6 @@ (); + 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 { 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(); + } + } } From b6ff3a110e0f505c5f676450ea3541b11d1d06e1 Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:18:20 -0800 Subject: [PATCH 08/13] [PM-18855] Add edit Cipher permission check to Cipher Authorization Service and use in Vault dialog (#18375) Centralize edit permission checks in CipherAuthorizationService instead of using the disableForm parameter passed to VaultItemDialogComponent. This refactoring improves consistency with how delete and restore permissions are handled, establishes a single source of truth for authorization logic, and simplifies caller components. This change also fixes the bug in ticket, which allows Users to properly edit Ciphers inside of the various Admin Console report types. --- .../collections/vault.component.ts | 7 +- .../reports/pages/cipher-report.component.ts | 3 - .../exposed-passwords-report.component.ts | 41 ++++++------ .../inactive-two-factor-report.component.ts | 24 ++++--- .../reused-passwords-report.component.ts | 44 +++++++------ .../unsecured-websites-report.component.ts | 46 ++++++------- .../weak-passwords-report.component.ts | 44 ++++++------- .../vault-item-dialog.component.ts | 23 +++++-- .../cipher-authorization.service.spec.ts | 64 +++++++++++++++++++ .../services/cipher-authorization.service.ts | 36 +++++++++++ 10 files changed, 218 insertions(+), 114 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 65cb6739887..073d73f6a50 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -920,14 +920,9 @@ export class VaultComponent implements OnInit, OnDestroy { cipher?: CipherView, activeCollectionId?: CollectionId, ) { - const organization = await firstValueFrom(this.organization$); - const disableForm = cipher ? !cipher.edit && !organization.canEditAllCiphers : false; - // If the form is disabled, force the mode into `view` - const dialogMode = disableForm ? "view" : mode; this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, { - mode: dialogMode, + mode, formConfig, - disableForm, activeCollectionId, isAdminConsoleAction: true, restore: this.restore, diff --git a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts index bd061bf34d3..c1955b0678b 100644 --- a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts @@ -186,13 +186,10 @@ export abstract class CipherReportComponent implements OnDestroy { cipher: CipherView, activeCollectionId?: CollectionId, ) { - const disableForm = cipher ? !cipher.edit && !this.organization?.canEditAllCiphers : false; - this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, { mode, formConfig, activeCollectionId, - disableForm, isAdminConsoleAction: this.organization != null, }); diff --git a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts index 6c81cbd9986..603c01bd2ab 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts @@ -1,17 +1,13 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, takeUntil, tap } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { getById } from "@bitwarden/common/platform/misc"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -51,7 +47,7 @@ export class ExposedPasswordsReportComponent extends BaseExposedPasswordsReportComponent implements OnInit { - manageableCiphers: Cipher[]; + private manageableCiphers: Cipher[] = []; constructor( cipherService: CipherService, @@ -82,20 +78,25 @@ export class ExposedPasswordsReportComponent async ngOnInit() { this.isAdminConsoleActive = true; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.parent.params.subscribe(async (params) => { - const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - this.organization = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(params.organizationId)), - ); - this.manageableCiphers = await this.cipherService.getAll(userId); - }); + this.route.parent?.parent?.params + .pipe( + tap(async (params) => { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + this.organization = await firstValueFrom( + this.organizationService.organizations$(userId).pipe(getById(params.organizationId)), + ); + this.manageableCiphers = await this.cipherService.getAll(userId); + }), + takeUntil(this.destroyed$), + ) + .subscribe(); } - getAllCiphers(): Promise { - 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/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$} */ From cb2e5a04d0c12d274bc871e3560ad3de3fe187ae Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Fri, 6 Feb 2026 12:36:44 -0600 Subject: [PATCH 09/13] [PM-29621] Changed error message to indicate lack of permissions (#18528) --- apps/web/src/locales/en/messages.json | 3 ++ .../password-change-metric.component.ts | 12 ++++- .../new-applications-dialog.component.ts | 41 ++++++++++------ .../critical-applications.component.ts | 47 +++++++++++-------- 4 files changed, 68 insertions(+), 35 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4d69bf45311..97bb46029a7 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10499,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/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"), + }); }); } From a637983305c5e142268d31a24b280bd31941c0cd Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Fri, 6 Feb 2026 14:56:38 -0500 Subject: [PATCH 10/13] [PM-30580] Add encryptMany to SDK for batch cipher encryption (#18803) * Migrated encrypt many to the sdk * removed comment * updated sdk package --- .../default-cipher-encryption.service.spec.ts | 27 ++++++++++++++----- .../default-cipher-encryption.service.ts | 19 +++++-------- package-lock.json | 16 +++++------ package.json | 4 +-- 4 files changed, 37 insertions(+), 29 deletions(-) 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/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", From f1b9408e3f0101fa5e24e058b0c7880ae9c06314 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:40:03 +0100 Subject: [PATCH 11/13] [PM-29149] Add ServerCommunicationConfigService (#18815) * Add state- and key-definitions for persisting serverCommunicationConfig(s) * Add implementation of the SDK-defined ServerCommunicationConfigRepository * Add ServerCommunicationConfigService --------- Co-authored-by: Daniel James Smith --- .../server-communication-config.service.ts | 27 +++ ...erver-communication-config.service.spec.ts | 146 +++++++++++++ ...ult-server-communication-config.service.ts | 47 +++++ .../server-communication-config/index.ts | 3 + ...er-communication-config.repository.spec.ts | 193 ++++++++++++++++++ .../server-communication-config.repository.ts | 57 ++++++ .../server-communication-config.state.ts | 18 ++ libs/state/src/core/state-definitions.ts | 4 + 8 files changed, 495 insertions(+) create mode 100644 libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts create mode 100644 libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts create mode 100644 libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts create mode 100644 libs/common/src/platform/services/server-communication-config/index.ts create mode 100644 libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts create mode 100644 libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts create mode 100644 libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts diff --git a/libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts b/libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts new file mode 100644 index 00000000000..19afebaa516 --- /dev/null +++ b/libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts @@ -0,0 +1,27 @@ +import { Observable } from "rxjs"; + +/** + * Service for managing server communication configuration, + * including bootstrap detection and cookie management. + */ +export abstract class ServerCommunicationConfigService { + /** + * Observable that emits true when the specified hostname + * requires bootstrap (cookie acquisition) before API calls can succeed. + * + * Automatically updates when server communication config state changes. + * + * @param hostname - The server hostname (e.g., "vault.acme.com") + * @returns Observable that emits bootstrap status for the hostname + */ + abstract needsBootstrap$(hostname: string): Observable; + + /** + * Retrieves cookies that should be included in HTTP requests + * to the specified hostname. + * + * @param hostname - The server hostname + * @returns Promise resolving to array of [cookie_name, cookie_value] tuples + */ + abstract getCookies(hostname: string): Promise>; +} diff --git a/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts b/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts new file mode 100644 index 00000000000..8e565d7ee1c --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts @@ -0,0 +1,146 @@ +import { firstValueFrom } from "rxjs"; + +import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; + +import { awaitAsync, FakeAccountService, FakeStateProvider } from "../../../../spec"; + +import { DefaultServerCommunicationConfigService } from "./default-server-communication-config.service"; +import { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; + +// Mock SDK client +jest.mock("@bitwarden/sdk-internal", () => ({ + ServerCommunicationConfigClient: jest.fn().mockImplementation(() => ({ + needsBootstrap: jest.fn(), + cookies: jest.fn(), + getConfig: jest.fn(), + })), +})); + +describe("DefaultServerCommunicationConfigService", () => { + let stateProvider: FakeStateProvider; + let repository: ServerCommunicationConfigRepository; + let service: DefaultServerCommunicationConfigService; + let mockClient: any; + + beforeEach(() => { + const accountService = new FakeAccountService({}); + stateProvider = new FakeStateProvider(accountService); + repository = new ServerCommunicationConfigRepository(stateProvider); + service = new DefaultServerCommunicationConfigService(repository); + mockClient = (service as any).client; + }); + + describe("needsBootstrap$", () => { + it("emits false when direct bootstrap configured", async () => { + mockClient.needsBootstrap.mockResolvedValue(false); + + const result = await firstValueFrom(service.needsBootstrap$("vault.bitwarden.com")); + + expect(result).toBe(false); + expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault.bitwarden.com"); + }); + + it("emits true when SSO cookie vendor bootstrap needed", async () => { + mockClient.needsBootstrap.mockResolvedValue(true); + + const result = await firstValueFrom(service.needsBootstrap$("vault.acme.com")); + + expect(result).toBe(true); + expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault.acme.com"); + }); + + it("re-emits when config state changes", async () => { + mockClient.needsBootstrap.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const observable = service.needsBootstrap$("vault.bitwarden.com"); + const emissions: boolean[] = []; + + // Subscribe to collect emissions + const subscription = observable.subscribe((value) => emissions.push(value)); + + // Wait for first emission + await awaitAsync(); + expect(emissions[0]).toBe(false); + + // Update config state to trigger re-check + const config: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + await repository.save("vault.bitwarden.com", config); + + // Wait for second emission + await awaitAsync(); + expect(emissions[1]).toBe(true); + + subscription.unsubscribe(); + }); + + it("creates independent observables per hostname", async () => { + mockClient.needsBootstrap.mockImplementation(async (hostname: string) => { + return hostname === "vault1.acme.com"; + }); + + const result1 = await firstValueFrom(service.needsBootstrap$("vault1.acme.com")); + const result2 = await firstValueFrom(service.needsBootstrap$("vault2.acme.com")); + + expect(result1).toBe(true); + expect(result2).toBe(false); + expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault1.acme.com"); + expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault2.acme.com"); + }); + + it("shares result between simultaneous subscribers", async () => { + mockClient.needsBootstrap.mockResolvedValue(true); + + const observable = service.needsBootstrap$("vault.bitwarden.com"); + + // Multiple simultaneous subscribers should share the same call + const [result1, result2] = await Promise.all([ + firstValueFrom(observable), + firstValueFrom(observable), + ]); + + expect(result1).toBe(true); + expect(result2).toBe(true); + // Should only call once for simultaneous subscribers + expect(mockClient.needsBootstrap).toHaveBeenCalledTimes(1); + }); + }); + + describe("getCookies", () => { + it("retrieves cookies for hostname", async () => { + const expectedCookies: Array<[string, string]> = [ + ["auth_token", "abc123"], + ["session_id", "xyz789"], + ]; + mockClient.cookies.mockResolvedValue(expectedCookies); + + const result = await service.getCookies("vault.bitwarden.com"); + + expect(result).toEqual(expectedCookies); + expect(mockClient.cookies).toHaveBeenCalledWith("vault.bitwarden.com"); + }); + + it("returns empty array when no cookies configured", async () => { + mockClient.cookies.mockResolvedValue([]); + + const result = await service.getCookies("vault.bitwarden.com"); + + expect(result).toEqual([]); + expect(mockClient.cookies).toHaveBeenCalledWith("vault.bitwarden.com"); + }); + + it("handles different hostnames independently", async () => { + mockClient.cookies + .mockResolvedValueOnce([["cookie1", "value1"]]) + .mockResolvedValueOnce([["cookie2", "value2"]]); + + const result1 = await service.getCookies("vault1.acme.com"); + const result2 = await service.getCookies("vault2.acme.com"); + + expect(result1).toEqual([["cookie1", "value1"]]); + expect(result2).toEqual([["cookie2", "value2"]]); + expect(mockClient.cookies).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts b/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts new file mode 100644 index 00000000000..b9194981622 --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts @@ -0,0 +1,47 @@ +import { Observable, shareReplay, switchMap } from "rxjs"; + +import { ServerCommunicationConfigClient } from "@bitwarden/sdk-internal"; + +import { ServerCommunicationConfigService } from "../../abstractions/server-communication-config/server-communication-config.service"; + +import { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; + +/** + * Default implementation of ServerCommunicationConfigService. + * + * Manages server communication configuration and bootstrap detection for different + * server environments. Provides reactive observables that automatically respond to + * configuration changes and integrate with the SDK's ServerCommunicationConfigClient. + * + * @remarks + * Bootstrap detection determines if SSO cookie acquisition is required before + * API calls can succeed. The service watches for configuration changes and + * re-evaluates bootstrap requirements automatically. + * + * Key features: + * - Reactive observables for bootstrap status (`needsBootstrap$`) + * - Per-hostname configuration management + * - Automatic re-evaluation when config state changes + * - Cookie retrieval for HTTP request headers + * + */ +export class DefaultServerCommunicationConfigService implements ServerCommunicationConfigService { + private client: ServerCommunicationConfigClient; + + constructor(private repository: ServerCommunicationConfigRepository) { + // Initialize SDK client with repository + this.client = new ServerCommunicationConfigClient(repository); + } + + needsBootstrap$(hostname: string): Observable { + // Watch hostname-specific config changes and re-check when it updates + return this.repository.get$(hostname).pipe( + switchMap(() => this.client.needsBootstrap(hostname)), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + } + + async getCookies(hostname: string): Promise> { + return this.client.cookies(hostname); + } +} diff --git a/libs/common/src/platform/services/server-communication-config/index.ts b/libs/common/src/platform/services/server-communication-config/index.ts new file mode 100644 index 00000000000..7d6bac1c067 --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/index.ts @@ -0,0 +1,3 @@ +export { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; +export { DefaultServerCommunicationConfigService } from "./default-server-communication-config.service"; +export { SERVER_COMMUNICATION_CONFIGS } from "./server-communication-config.state"; diff --git a/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts b/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts new file mode 100644 index 00000000000..2ed16e96c11 --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts @@ -0,0 +1,193 @@ +import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; + +import { FakeAccountService, FakeStateProvider } from "../../../../spec"; + +import { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; + +describe("ServerCommunicationConfigRepository", () => { + let stateProvider: FakeStateProvider; + let repository: ServerCommunicationConfigRepository; + + beforeEach(() => { + const accountService = new FakeAccountService({}); + stateProvider = new FakeStateProvider(accountService); + repository = new ServerCommunicationConfigRepository(stateProvider); + }); + + it("returns undefined when no config exists for hostname", async () => { + const result = await repository.get("vault.acme.com"); + + expect(result).toBeUndefined(); + }); + + it("saves and retrieves a direct bootstrap config", async () => { + const config: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + + await repository.save("vault.acme.com", config); + const result = await repository.get("vault.acme.com"); + + expect(result).toEqual(config); + }); + + it("saves and retrieves an SSO cookie vendor config", async () => { + const config: ServerCommunicationConfig = { + bootstrap: { + type: "ssoCookieVendor", + idp_login_url: "https://idp.example.com/login", + cookie_name: "auth_token", + cookie_domain: ".acme.com", + cookie_value: "abc123", + }, + }; + + await repository.save("vault.acme.com", config); + const result = await repository.get("vault.acme.com"); + + expect(result).toEqual(config); + }); + + it("handles SSO config with undefined cookie_value", async () => { + const config: ServerCommunicationConfig = { + bootstrap: { + type: "ssoCookieVendor", + idp_login_url: "https://idp.example.com/login", + cookie_name: "auth_token", + cookie_domain: ".acme.com", + cookie_value: undefined, + }, + }; + + await repository.save("vault.acme.com", config); + const result = await repository.get("vault.acme.com"); + + expect(result).toEqual(config); + }); + + it("overwrites existing config for same hostname", async () => { + const initialConfig: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + await repository.save("vault.acme.com", initialConfig); + + const newConfig: ServerCommunicationConfig = { + bootstrap: { + type: "ssoCookieVendor", + idp_login_url: "https://idp.example.com", + cookie_name: "token", + cookie_domain: ".acme.com", + cookie_value: "xyz789", + }, + }; + await repository.save("vault.acme.com", newConfig); + + const result = await repository.get("vault.acme.com"); + expect(result).toEqual(newConfig); + }); + + it("maintains separate configs for different hostnames", async () => { + const config1: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + const config2: ServerCommunicationConfig = { + bootstrap: { + type: "ssoCookieVendor", + idp_login_url: "https://idp.example.com", + cookie_name: "token", + cookie_domain: ".example.com", + cookie_value: "token123", + }, + }; + + await repository.save("vault1.acme.com", config1); + await repository.save("vault2.example.com", config2); + + const result1 = await repository.get("vault1.acme.com"); + const result2 = await repository.get("vault2.example.com"); + + expect(result1).toEqual(config1); + expect(result2).toEqual(config2); + }); + + describe("get$", () => { + it("emits undefined for hostname with no config", (done) => { + repository.get$("vault.acme.com").subscribe((config) => { + expect(config).toBeUndefined(); + done(); + }); + }); + + it("emits config when it exists", async () => { + const config: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + + await repository.save("vault.acme.com", config); + + repository.get$("vault.acme.com").subscribe((result) => { + expect(result).toEqual(config); + }); + }); + + it("emits new value when config changes", (done) => { + const config1: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + const config2: ServerCommunicationConfig = { + bootstrap: { + type: "ssoCookieVendor", + idp_login_url: "https://idp.example.com", + cookie_name: "token", + cookie_domain: ".acme.com", + cookie_value: "abc123", + }, + }; + + const emissions: (ServerCommunicationConfig | undefined)[] = []; + const subscription = repository.get$("vault.acme.com").subscribe((config) => { + emissions.push(config); + + if (emissions.length === 3) { + expect(emissions[0]).toBeUndefined(); + expect(emissions[1]).toEqual(config1); + expect(emissions[2]).toEqual(config2); + subscription.unsubscribe(); + done(); + } + }); + + // Trigger updates + setTimeout(async () => { + await repository.save("vault.acme.com", config1); + setTimeout(async () => { + await repository.save("vault.acme.com", config2); + }, 10); + }, 10); + }); + + it("only emits when the specific hostname config changes", (done) => { + const config1: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + + const emissions: (ServerCommunicationConfig | undefined)[] = []; + const subscription = repository.get$("vault1.acme.com").subscribe((config) => { + emissions.push(config); + }); + + setTimeout(async () => { + // Save config for different hostname - should not trigger emission for vault1 + await repository.save("vault2.acme.com", config1); + + setTimeout(() => { + // Only initial undefined emission should exist + expect(emissions.length).toBe(1); + expect(emissions[0]).toBeUndefined(); + subscription.unsubscribe(); + done(); + }, 50); + }, 10); + }); + }); +}); diff --git a/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts b/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts new file mode 100644 index 00000000000..7ca38be1e6e --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts @@ -0,0 +1,57 @@ +import { distinctUntilChanged, firstValueFrom, map, Observable } from "rxjs"; + +import { + ServerCommunicationConfigRepository as SdkRepository, + ServerCommunicationConfig, +} from "@bitwarden/sdk-internal"; + +import { GlobalState, StateProvider } from "../../state"; + +import { SERVER_COMMUNICATION_CONFIGS } from "./server-communication-config.state"; + +/** + * Implementation of SDK-defined interface. + * Bridges the SDK's repository abstraction with StateProvider for persistence. + * + * This repository manages server communication configurations keyed by hostname, + * storing information about bootstrap requirements (direct vs SSO cookie vendor) + * for each server environment. + * + * @remarks + * - Uses global state (application-level, not user-scoped) + * - Configurations persist across sessions (stored on disk) + * - Each hostname maintains independent configuration + * - All error handling is performed by the SDK caller + * + */ +export class ServerCommunicationConfigRepository implements SdkRepository { + private state: GlobalState>; + + constructor(private stateProvider: StateProvider) { + this.state = this.stateProvider.getGlobal(SERVER_COMMUNICATION_CONFIGS); + } + + async get(hostname: string): Promise { + return firstValueFrom(this.get$(hostname)); + } + + /** + * Observable that emits when the configuration for a specific hostname changes. + * + * @param hostname - The server hostname + * @returns Observable that emits the config for the hostname, or undefined if not set + */ + get$(hostname: string): Observable { + return this.state.state$.pipe( + map((configs) => configs?.[hostname]), + distinctUntilChanged(), + ); + } + + async save(hostname: string, config: ServerCommunicationConfig): Promise { + await this.state.update((configs) => ({ + ...configs, + [hostname]: config, + })); + } +} diff --git a/libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts b/libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts new file mode 100644 index 00000000000..65bc692df5f --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts @@ -0,0 +1,18 @@ +import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; + +import { KeyDefinition, SERVER_COMMUNICATION_CONFIG_DISK } from "../../state"; + +/** + * Key definition for server communication configurations. + * + * Record type: Maps hostname (string) to ServerCommunicationConfig + * Storage: Disk (persisted across sessions) + * Scope: Global (application-level, not user-specific) + */ +export const SERVER_COMMUNICATION_CONFIGS = KeyDefinition.record( + SERVER_COMMUNICATION_CONFIG_DISK, + "configs", + { + deserializer: (value: ServerCommunicationConfig) => value, + }, +); diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index ae6938b2069..f9113b7e64e 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -137,6 +137,10 @@ export const EXTENSION_INITIAL_INSTALL_DISK = new StateDefinition( export const WEB_PUSH_SUBSCRIPTION = new StateDefinition("webPushSubscription", "disk", { web: "disk-local", }); +export const SERVER_COMMUNICATION_CONFIG_DISK = new StateDefinition( + "serverCommunicationConfig", + "disk", +); // Design System From 42386ddd6072f123402f19f15d95c8ec31ef014c Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Fri, 6 Feb 2026 16:08:01 -0500 Subject: [PATCH 12/13] [PM-22119] Update icon for password protected Sends on Desktop (#18659) * [PM-22119] Update icon for password protected Sends on Desktop * Mute Send type icons to match web --- .../src/app/tools/send/send.component.html | 19 ++++++++----- .../src/app/tools/send/send.component.ts | 3 +++ apps/desktop/src/locales/en/messages.json | 27 +++++++++++++++++++ apps/desktop/src/scss/list.scss | 14 +++++++--- 4 files changed, 52 insertions(+), 11 deletions(-) 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 }} + } Date: Fri, 6 Feb 2026 22:15:12 +0100 Subject: [PATCH 13/13] Revert "[PM-29149] Add ServerCommunicationConfigService (#18815)" (#18821) This reverts commit f1b9408e3f0101fa5e24e058b0c7880ae9c06314. --- .../server-communication-config.service.ts | 27 --- ...erver-communication-config.service.spec.ts | 146 ------------- ...ult-server-communication-config.service.ts | 47 ----- .../server-communication-config/index.ts | 3 - ...er-communication-config.repository.spec.ts | 193 ------------------ .../server-communication-config.repository.ts | 57 ------ .../server-communication-config.state.ts | 18 -- libs/state/src/core/state-definitions.ts | 4 - 8 files changed, 495 deletions(-) delete mode 100644 libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts delete mode 100644 libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts delete mode 100644 libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts delete mode 100644 libs/common/src/platform/services/server-communication-config/index.ts delete mode 100644 libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts delete mode 100644 libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts delete mode 100644 libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts diff --git a/libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts b/libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts deleted file mode 100644 index 19afebaa516..00000000000 --- a/libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Observable } from "rxjs"; - -/** - * Service for managing server communication configuration, - * including bootstrap detection and cookie management. - */ -export abstract class ServerCommunicationConfigService { - /** - * Observable that emits true when the specified hostname - * requires bootstrap (cookie acquisition) before API calls can succeed. - * - * Automatically updates when server communication config state changes. - * - * @param hostname - The server hostname (e.g., "vault.acme.com") - * @returns Observable that emits bootstrap status for the hostname - */ - abstract needsBootstrap$(hostname: string): Observable; - - /** - * Retrieves cookies that should be included in HTTP requests - * to the specified hostname. - * - * @param hostname - The server hostname - * @returns Promise resolving to array of [cookie_name, cookie_value] tuples - */ - abstract getCookies(hostname: string): Promise>; -} diff --git a/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts b/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts deleted file mode 100644 index 8e565d7ee1c..00000000000 --- a/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { firstValueFrom } from "rxjs"; - -import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; - -import { awaitAsync, FakeAccountService, FakeStateProvider } from "../../../../spec"; - -import { DefaultServerCommunicationConfigService } from "./default-server-communication-config.service"; -import { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; - -// Mock SDK client -jest.mock("@bitwarden/sdk-internal", () => ({ - ServerCommunicationConfigClient: jest.fn().mockImplementation(() => ({ - needsBootstrap: jest.fn(), - cookies: jest.fn(), - getConfig: jest.fn(), - })), -})); - -describe("DefaultServerCommunicationConfigService", () => { - let stateProvider: FakeStateProvider; - let repository: ServerCommunicationConfigRepository; - let service: DefaultServerCommunicationConfigService; - let mockClient: any; - - beforeEach(() => { - const accountService = new FakeAccountService({}); - stateProvider = new FakeStateProvider(accountService); - repository = new ServerCommunicationConfigRepository(stateProvider); - service = new DefaultServerCommunicationConfigService(repository); - mockClient = (service as any).client; - }); - - describe("needsBootstrap$", () => { - it("emits false when direct bootstrap configured", async () => { - mockClient.needsBootstrap.mockResolvedValue(false); - - const result = await firstValueFrom(service.needsBootstrap$("vault.bitwarden.com")); - - expect(result).toBe(false); - expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault.bitwarden.com"); - }); - - it("emits true when SSO cookie vendor bootstrap needed", async () => { - mockClient.needsBootstrap.mockResolvedValue(true); - - const result = await firstValueFrom(service.needsBootstrap$("vault.acme.com")); - - expect(result).toBe(true); - expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault.acme.com"); - }); - - it("re-emits when config state changes", async () => { - mockClient.needsBootstrap.mockResolvedValueOnce(false).mockResolvedValueOnce(true); - - const observable = service.needsBootstrap$("vault.bitwarden.com"); - const emissions: boolean[] = []; - - // Subscribe to collect emissions - const subscription = observable.subscribe((value) => emissions.push(value)); - - // Wait for first emission - await awaitAsync(); - expect(emissions[0]).toBe(false); - - // Update config state to trigger re-check - const config: ServerCommunicationConfig = { - bootstrap: { type: "direct" }, - }; - await repository.save("vault.bitwarden.com", config); - - // Wait for second emission - await awaitAsync(); - expect(emissions[1]).toBe(true); - - subscription.unsubscribe(); - }); - - it("creates independent observables per hostname", async () => { - mockClient.needsBootstrap.mockImplementation(async (hostname: string) => { - return hostname === "vault1.acme.com"; - }); - - const result1 = await firstValueFrom(service.needsBootstrap$("vault1.acme.com")); - const result2 = await firstValueFrom(service.needsBootstrap$("vault2.acme.com")); - - expect(result1).toBe(true); - expect(result2).toBe(false); - expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault1.acme.com"); - expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault2.acme.com"); - }); - - it("shares result between simultaneous subscribers", async () => { - mockClient.needsBootstrap.mockResolvedValue(true); - - const observable = service.needsBootstrap$("vault.bitwarden.com"); - - // Multiple simultaneous subscribers should share the same call - const [result1, result2] = await Promise.all([ - firstValueFrom(observable), - firstValueFrom(observable), - ]); - - expect(result1).toBe(true); - expect(result2).toBe(true); - // Should only call once for simultaneous subscribers - expect(mockClient.needsBootstrap).toHaveBeenCalledTimes(1); - }); - }); - - describe("getCookies", () => { - it("retrieves cookies for hostname", async () => { - const expectedCookies: Array<[string, string]> = [ - ["auth_token", "abc123"], - ["session_id", "xyz789"], - ]; - mockClient.cookies.mockResolvedValue(expectedCookies); - - const result = await service.getCookies("vault.bitwarden.com"); - - expect(result).toEqual(expectedCookies); - expect(mockClient.cookies).toHaveBeenCalledWith("vault.bitwarden.com"); - }); - - it("returns empty array when no cookies configured", async () => { - mockClient.cookies.mockResolvedValue([]); - - const result = await service.getCookies("vault.bitwarden.com"); - - expect(result).toEqual([]); - expect(mockClient.cookies).toHaveBeenCalledWith("vault.bitwarden.com"); - }); - - it("handles different hostnames independently", async () => { - mockClient.cookies - .mockResolvedValueOnce([["cookie1", "value1"]]) - .mockResolvedValueOnce([["cookie2", "value2"]]); - - const result1 = await service.getCookies("vault1.acme.com"); - const result2 = await service.getCookies("vault2.acme.com"); - - expect(result1).toEqual([["cookie1", "value1"]]); - expect(result2).toEqual([["cookie2", "value2"]]); - expect(mockClient.cookies).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts b/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts deleted file mode 100644 index b9194981622..00000000000 --- a/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Observable, shareReplay, switchMap } from "rxjs"; - -import { ServerCommunicationConfigClient } from "@bitwarden/sdk-internal"; - -import { ServerCommunicationConfigService } from "../../abstractions/server-communication-config/server-communication-config.service"; - -import { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; - -/** - * Default implementation of ServerCommunicationConfigService. - * - * Manages server communication configuration and bootstrap detection for different - * server environments. Provides reactive observables that automatically respond to - * configuration changes and integrate with the SDK's ServerCommunicationConfigClient. - * - * @remarks - * Bootstrap detection determines if SSO cookie acquisition is required before - * API calls can succeed. The service watches for configuration changes and - * re-evaluates bootstrap requirements automatically. - * - * Key features: - * - Reactive observables for bootstrap status (`needsBootstrap$`) - * - Per-hostname configuration management - * - Automatic re-evaluation when config state changes - * - Cookie retrieval for HTTP request headers - * - */ -export class DefaultServerCommunicationConfigService implements ServerCommunicationConfigService { - private client: ServerCommunicationConfigClient; - - constructor(private repository: ServerCommunicationConfigRepository) { - // Initialize SDK client with repository - this.client = new ServerCommunicationConfigClient(repository); - } - - needsBootstrap$(hostname: string): Observable { - // Watch hostname-specific config changes and re-check when it updates - return this.repository.get$(hostname).pipe( - switchMap(() => this.client.needsBootstrap(hostname)), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - } - - async getCookies(hostname: string): Promise> { - return this.client.cookies(hostname); - } -} diff --git a/libs/common/src/platform/services/server-communication-config/index.ts b/libs/common/src/platform/services/server-communication-config/index.ts deleted file mode 100644 index 7d6bac1c067..00000000000 --- a/libs/common/src/platform/services/server-communication-config/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; -export { DefaultServerCommunicationConfigService } from "./default-server-communication-config.service"; -export { SERVER_COMMUNICATION_CONFIGS } from "./server-communication-config.state"; diff --git a/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts b/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts deleted file mode 100644 index 2ed16e96c11..00000000000 --- a/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; - -import { FakeAccountService, FakeStateProvider } from "../../../../spec"; - -import { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; - -describe("ServerCommunicationConfigRepository", () => { - let stateProvider: FakeStateProvider; - let repository: ServerCommunicationConfigRepository; - - beforeEach(() => { - const accountService = new FakeAccountService({}); - stateProvider = new FakeStateProvider(accountService); - repository = new ServerCommunicationConfigRepository(stateProvider); - }); - - it("returns undefined when no config exists for hostname", async () => { - const result = await repository.get("vault.acme.com"); - - expect(result).toBeUndefined(); - }); - - it("saves and retrieves a direct bootstrap config", async () => { - const config: ServerCommunicationConfig = { - bootstrap: { type: "direct" }, - }; - - await repository.save("vault.acme.com", config); - const result = await repository.get("vault.acme.com"); - - expect(result).toEqual(config); - }); - - it("saves and retrieves an SSO cookie vendor config", async () => { - const config: ServerCommunicationConfig = { - bootstrap: { - type: "ssoCookieVendor", - idp_login_url: "https://idp.example.com/login", - cookie_name: "auth_token", - cookie_domain: ".acme.com", - cookie_value: "abc123", - }, - }; - - await repository.save("vault.acme.com", config); - const result = await repository.get("vault.acme.com"); - - expect(result).toEqual(config); - }); - - it("handles SSO config with undefined cookie_value", async () => { - const config: ServerCommunicationConfig = { - bootstrap: { - type: "ssoCookieVendor", - idp_login_url: "https://idp.example.com/login", - cookie_name: "auth_token", - cookie_domain: ".acme.com", - cookie_value: undefined, - }, - }; - - await repository.save("vault.acme.com", config); - const result = await repository.get("vault.acme.com"); - - expect(result).toEqual(config); - }); - - it("overwrites existing config for same hostname", async () => { - const initialConfig: ServerCommunicationConfig = { - bootstrap: { type: "direct" }, - }; - await repository.save("vault.acme.com", initialConfig); - - const newConfig: ServerCommunicationConfig = { - bootstrap: { - type: "ssoCookieVendor", - idp_login_url: "https://idp.example.com", - cookie_name: "token", - cookie_domain: ".acme.com", - cookie_value: "xyz789", - }, - }; - await repository.save("vault.acme.com", newConfig); - - const result = await repository.get("vault.acme.com"); - expect(result).toEqual(newConfig); - }); - - it("maintains separate configs for different hostnames", async () => { - const config1: ServerCommunicationConfig = { - bootstrap: { type: "direct" }, - }; - const config2: ServerCommunicationConfig = { - bootstrap: { - type: "ssoCookieVendor", - idp_login_url: "https://idp.example.com", - cookie_name: "token", - cookie_domain: ".example.com", - cookie_value: "token123", - }, - }; - - await repository.save("vault1.acme.com", config1); - await repository.save("vault2.example.com", config2); - - const result1 = await repository.get("vault1.acme.com"); - const result2 = await repository.get("vault2.example.com"); - - expect(result1).toEqual(config1); - expect(result2).toEqual(config2); - }); - - describe("get$", () => { - it("emits undefined for hostname with no config", (done) => { - repository.get$("vault.acme.com").subscribe((config) => { - expect(config).toBeUndefined(); - done(); - }); - }); - - it("emits config when it exists", async () => { - const config: ServerCommunicationConfig = { - bootstrap: { type: "direct" }, - }; - - await repository.save("vault.acme.com", config); - - repository.get$("vault.acme.com").subscribe((result) => { - expect(result).toEqual(config); - }); - }); - - it("emits new value when config changes", (done) => { - const config1: ServerCommunicationConfig = { - bootstrap: { type: "direct" }, - }; - const config2: ServerCommunicationConfig = { - bootstrap: { - type: "ssoCookieVendor", - idp_login_url: "https://idp.example.com", - cookie_name: "token", - cookie_domain: ".acme.com", - cookie_value: "abc123", - }, - }; - - const emissions: (ServerCommunicationConfig | undefined)[] = []; - const subscription = repository.get$("vault.acme.com").subscribe((config) => { - emissions.push(config); - - if (emissions.length === 3) { - expect(emissions[0]).toBeUndefined(); - expect(emissions[1]).toEqual(config1); - expect(emissions[2]).toEqual(config2); - subscription.unsubscribe(); - done(); - } - }); - - // Trigger updates - setTimeout(async () => { - await repository.save("vault.acme.com", config1); - setTimeout(async () => { - await repository.save("vault.acme.com", config2); - }, 10); - }, 10); - }); - - it("only emits when the specific hostname config changes", (done) => { - const config1: ServerCommunicationConfig = { - bootstrap: { type: "direct" }, - }; - - const emissions: (ServerCommunicationConfig | undefined)[] = []; - const subscription = repository.get$("vault1.acme.com").subscribe((config) => { - emissions.push(config); - }); - - setTimeout(async () => { - // Save config for different hostname - should not trigger emission for vault1 - await repository.save("vault2.acme.com", config1); - - setTimeout(() => { - // Only initial undefined emission should exist - expect(emissions.length).toBe(1); - expect(emissions[0]).toBeUndefined(); - subscription.unsubscribe(); - done(); - }, 50); - }, 10); - }); - }); -}); diff --git a/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts b/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts deleted file mode 100644 index 7ca38be1e6e..00000000000 --- a/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { distinctUntilChanged, firstValueFrom, map, Observable } from "rxjs"; - -import { - ServerCommunicationConfigRepository as SdkRepository, - ServerCommunicationConfig, -} from "@bitwarden/sdk-internal"; - -import { GlobalState, StateProvider } from "../../state"; - -import { SERVER_COMMUNICATION_CONFIGS } from "./server-communication-config.state"; - -/** - * Implementation of SDK-defined interface. - * Bridges the SDK's repository abstraction with StateProvider for persistence. - * - * This repository manages server communication configurations keyed by hostname, - * storing information about bootstrap requirements (direct vs SSO cookie vendor) - * for each server environment. - * - * @remarks - * - Uses global state (application-level, not user-scoped) - * - Configurations persist across sessions (stored on disk) - * - Each hostname maintains independent configuration - * - All error handling is performed by the SDK caller - * - */ -export class ServerCommunicationConfigRepository implements SdkRepository { - private state: GlobalState>; - - constructor(private stateProvider: StateProvider) { - this.state = this.stateProvider.getGlobal(SERVER_COMMUNICATION_CONFIGS); - } - - async get(hostname: string): Promise { - return firstValueFrom(this.get$(hostname)); - } - - /** - * Observable that emits when the configuration for a specific hostname changes. - * - * @param hostname - The server hostname - * @returns Observable that emits the config for the hostname, or undefined if not set - */ - get$(hostname: string): Observable { - return this.state.state$.pipe( - map((configs) => configs?.[hostname]), - distinctUntilChanged(), - ); - } - - async save(hostname: string, config: ServerCommunicationConfig): Promise { - await this.state.update((configs) => ({ - ...configs, - [hostname]: config, - })); - } -} diff --git a/libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts b/libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts deleted file mode 100644 index 65bc692df5f..00000000000 --- a/libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; - -import { KeyDefinition, SERVER_COMMUNICATION_CONFIG_DISK } from "../../state"; - -/** - * Key definition for server communication configurations. - * - * Record type: Maps hostname (string) to ServerCommunicationConfig - * Storage: Disk (persisted across sessions) - * Scope: Global (application-level, not user-specific) - */ -export const SERVER_COMMUNICATION_CONFIGS = KeyDefinition.record( - SERVER_COMMUNICATION_CONFIG_DISK, - "configs", - { - deserializer: (value: ServerCommunicationConfig) => value, - }, -); diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index f9113b7e64e..ae6938b2069 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -137,10 +137,6 @@ export const EXTENSION_INITIAL_INSTALL_DISK = new StateDefinition( export const WEB_PUSH_SUBSCRIPTION = new StateDefinition("webPushSubscription", "disk", { web: "disk-local", }); -export const SERVER_COMMUNICATION_CONFIG_DISK = new StateDefinition( - "serverCommunicationConfig", - "disk", -); // Design System