From a04566ae11e10db93972b52f86aec35aa5f7f8ce Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Wed, 28 Jan 2026 08:25:10 -0500 Subject: [PATCH 01/13] chore(flags): [PM-31326] Rename ipc-channel-framework feature flag * Rename feature flag * Not sure what happened here. Renaming the class. --- .../src/platform/ipc/ipc-content-script-manager.service.ts | 2 +- libs/common/src/enums/feature-flag.enum.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts b/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts index e5fe95e2018..d53347b9dce 100644 --- a/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts +++ b/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts @@ -15,7 +15,7 @@ export class IpcContentScriptManagerService { } configService - .getFeatureFlag$(FeatureFlag.IpcChannelFramework) + .getFeatureFlag$(FeatureFlag.ContentScriptIpcChannelFramework) .pipe( mergeMap(async (enabled) => { if (!enabled) { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 819ae8bd8e2..35fa520f34a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -71,7 +71,7 @@ export enum FeatureFlag { PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", /* Platform */ - IpcChannelFramework = "ipc-channel-framework", + ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework", /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", @@ -162,7 +162,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE, /* Platform */ - [FeatureFlag.IpcChannelFramework]: FALSE, + [FeatureFlag.ContentScriptIpcChannelFramework]: FALSE, /* Innovation */ [FeatureFlag.PM19148_InnovationArchive]: FALSE, From c2da621663b06b7a5279e730c17c8e1df87bc8c5 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Wed, 28 Jan 2026 09:31:55 -0500 Subject: [PATCH 02/13] [PM-28413] Remove feature flagged logic (#18566) * clean up flagged logic * fix test --- .../common/people-table-data-source.spec.ts | 12 +- .../common/people-table-data-source.ts | 25 +- .../members/deprecated_members.component.ts | 4 +- .../members/members.component.ts | 10 +- .../member-actions.service.spec.ts | 471 ++++++++---------- .../member-actions/member-actions.service.ts | 25 +- .../manage/deprecated_members.component.ts | 4 +- .../providers/manage/members.component.ts | 10 +- libs/common/src/enums/feature-flag.enum.ts | 2 - 9 files changed, 225 insertions(+), 338 deletions(-) diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts b/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts index e9cf87a114d..e174d01a75d 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts @@ -2,7 +2,6 @@ import { TestBed } from "@angular/core/testing"; import { ReplaySubject } from "rxjs"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Environment, EnvironmentService, @@ -46,23 +45,16 @@ describe("PeopleTableDataSource", () => { isCloud: () => false, } as Environment); - const mockConfigService = { - getFeatureFlag$: jest.fn(() => featureFlagSubject.asObservable()), - } as any; - const mockEnvironmentService = { environment$: environmentSubject.asObservable(), } as any; TestBed.configureTestingModule({ - providers: [ - { provide: ConfigService, useValue: mockConfigService }, - { provide: EnvironmentService, useValue: mockEnvironmentService }, - ], + providers: [{ provide: EnvironmentService, useValue: mockEnvironmentService }], }); dataSource = TestBed.runInInjectionContext( - () => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService), + () => new TestPeopleTableDataSource(mockEnvironmentService), ); }); diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.ts b/apps/web/src/app/admin-console/common/people-table-data-source.ts index d39a4f29653..a3ffbaeb7b5 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { computed, Signal } from "@angular/core"; +import { Signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { Observable, Subject, map } from "rxjs"; @@ -9,8 +9,6 @@ import { ProviderUserStatusType, } from "@bitwarden/common/admin-console/enums"; import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { TableDataSource } from "@bitwarden/components"; @@ -27,8 +25,7 @@ export type ProviderUser = ProviderUserUserDetailsResponse; export const MaxCheckedCount = 500; /** - * Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud - * feature flag is enabled on cloud environments. + * Maximum for bulk reinvite limit in cloud environments. */ export const CloudBulkReinviteLimit = 8000; @@ -78,18 +75,15 @@ export abstract class PeopleTableDataSource extends Tab confirmedUserCount: number; revokedUserCount: number; - /** True when increased bulk limit feature is enabled (feature flag + cloud environment) */ + /** True when increased bulk limit feature is enabled (cloud environment) */ readonly isIncreasedBulkLimitEnabled: Signal; - constructor(configService: ConfigService, environmentService: EnvironmentService) { + constructor(environmentService: EnvironmentService) { super(); - const featureFlagEnabled = toSignal( - configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + this.isIncreasedBulkLimitEnabled = toSignal( + environmentService.environment$.pipe(map((env) => env.isCloud())), ); - const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud()))); - - this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud()); } override set data(data: T[]) { @@ -224,12 +218,9 @@ export abstract class PeopleTableDataSource extends Tab } /** - * Gets checked users with optional limiting based on the IncreaseBulkReinviteLimitForCloud feature flag. + * Returns checked users in visible order, optionally limited to the specified count. * - * When the feature flag is enabled: Returns checked users in visible order, limited to the specified count. - * When the feature flag is disabled: Returns all checked users without applying any limit. - * - * @param limit The maximum number of users to return (only applied when feature flag is enabled) + * @param limit The maximum number of users to return * @returns The checked users array */ getCheckedUsersWithLimit(limit: number): T[] { diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts index 99fd81aa48d..93960820fbb 100644 --- a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts @@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -124,7 +123,6 @@ export class MembersComponent extends BaseMembersComponent private policyApiService: PolicyApiServiceAbstraction, private organizationMetadataService: OrganizationMetadataServiceAbstraction, private memberExportService: MemberExportService, - private configService: ConfigService, private environmentService: EnvironmentService, ) { super( @@ -139,7 +137,7 @@ export class MembersComponent extends BaseMembersComponent toastService, ); - this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + this.dataSource = new MembersTableDataSource(this.environmentService); const organization$ = this.route.params.pipe( concatMap((params) => diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 84b4cba7c2d..e3ed575d81b 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -100,7 +99,6 @@ export class vNextMembersComponent { private policyService = inject(PolicyService); private policyApiService = inject(PolicyApiServiceAbstraction); private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); - private configService = inject(ConfigService); private environmentService = inject(EnvironmentService); private memberExportService = inject(MemberExportService); @@ -114,7 +112,7 @@ export class vNextMembersComponent { protected statusToggle = new BehaviorSubject(undefined); protected readonly dataSource: Signal = signal( - new MembersTableDataSource(this.configService, this.environmentService), + new MembersTableDataSource(this.environmentService), ); protected readonly organization: Signal; protected readonly firstLoaded: WritableSignal = signal(false); @@ -389,7 +387,7 @@ export class vNextMembersComponent { // Capture the original count BEFORE enforcing the limit const originalInvitedCount = allInvitedUsers.length; - // When feature flag is enabled, limit invited users and uncheck the excess + // In cloud environments, limit invited users and uncheck the excess let filteredUsers: OrganizationUserView[]; if (this.dataSource().isIncreasedBulkLimitEnabled()) { filteredUsers = this.dataSource().limitAndUncheckExcess( @@ -418,7 +416,7 @@ export class vNextMembersComponent { this.validationService.showError(result.failed); } - // When feature flag is enabled, show toast instead of dialog + // In cloud environments, show toast instead of dialog if (this.dataSource().isIncreasedBulkLimitEnabled()) { const selectedCount = originalInvitedCount; const invitedCount = filteredUsers.length; @@ -441,7 +439,7 @@ export class vNextMembersComponent { }); } } else { - // Feature flag disabled - show legacy dialog + // In self-hosted environments, show legacy dialog await this.memberDialogManager.openBulkStatusDialog( users, filteredUsers, diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 1df285d7ba2..423977e73c4 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -17,7 +17,6 @@ import { import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; @@ -32,7 +31,6 @@ describe("MemberActionsService", () => { let service: MemberActionsService; let organizationUserApiService: MockProxy; let organizationUserService: MockProxy; - let configService: MockProxy; let organizationMetadataService: MockProxy; const organizationId = newGuid() as OrganizationId; @@ -44,7 +42,6 @@ describe("MemberActionsService", () => { beforeEach(() => { organizationUserApiService = mock(); organizationUserService = mock(); - configService = mock(); organizationMetadataService = mock(); mockOrganization = { @@ -68,7 +65,6 @@ describe("MemberActionsService", () => { MemberActionsService, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, { provide: OrganizationUserService, useValue: organizationUserService }, - { provide: ConfigService, useValue: configService }, { provide: OrganizationMetadataServiceAbstraction, useValue: organizationMetadataService, @@ -279,308 +275,247 @@ describe("MemberActionsService", () => { }); describe("bulkReinvite", () => { - const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId]; + it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { + const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); + const mockResponse = new ListResponse( + { + data: userIdsBatch.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - describe("when feature flag is false", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - }); + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - it("should successfully reinvite multiple users", async () => { - const mockResponse = new ListResponse( - { - data: userIds.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - - const result = await service.bulkReinvite(mockOrganization, userIds); - - expect(result.failed).toEqual([]); - expect(result.successful).toBeDefined(); - expect(result.successful).toEqual(mockResponse); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( - organizationId, - userIds, - ); - }); - - it("should handle bulk reinvite errors", async () => { - const errorMessage = "Bulk reinvite failed"; - organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( - new Error(errorMessage), - ); - - const result = await service.bulkReinvite(mockOrganization, userIds); - - expect(result.successful).toBeUndefined(); - expect(result.failed).toHaveLength(3); - expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage }); - }); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(1); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( + organizationId, + userIdsBatch, + ); }); - describe("when feature flag is true (batching behavior)", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - }); - it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { - const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); - const mockResponse = new ListResponse( - { - data: userIdsBatch.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); - expect(result.failed).toHaveLength(0); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 1, - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( - organizationId, - userIdsBatch, - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { - const totalUsers = REQUESTS_PER_BATCH + 100; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 1, + organizationId, + userIdsBatch.slice(0, REQUESTS_PER_BATCH), + ); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 2, + organizationId, + userIdsBatch.slice(REQUESTS_PER_BATCH), + ); + }); - const mockResponse2 = new ListResponse( - { - data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should aggregate results across multiple successful batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); - expect(result.failed).toHaveLength(0); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( - 1, - organizationId, - userIdsBatch.slice(0, REQUESTS_PER_BATCH), - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( - 2, - organizationId, - userIdsBatch.slice(REQUESTS_PER_BATCH), - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should aggregate results across multiple successful batches", async () => { - const totalUsers = REQUESTS_PER_BATCH + 50; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data); + expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); + expect(result.failed).toHaveLength(0); + }); - const mockResponse2 = new ListResponse( - { - data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should handle mixed individual errors across multiple batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 4; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({ + id, + error: index % 10 === 0 ? "Rate limit exceeded" : null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: [ + { id: userIdsBatch[REQUESTS_PER_BATCH], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" }, + ], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); - expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual( - mockResponse1.data, - ); - expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); - expect(result.failed).toHaveLength(0); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should handle mixed individual errors across multiple batches", async () => { - const totalUsers = REQUESTS_PER_BATCH + 4; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({ - id, - error: index % 10 === 0 ? "Rate limit exceeded" : null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch + // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values + const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1; + const expectedFailuresInBatch2 = 2; + const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2; + const expectedSuccesses = totalUsers - expectedTotalFailures; - const mockResponse2 = new ListResponse( - { - data: [ - { id: userIdsBatch[REQUESTS_PER_BATCH], error: null }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" }, - ], - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(expectedSuccesses); + expect(result.failed).toHaveLength(expectedTotalFailures); + expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); + expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); + expect(result.failed.some((f) => f.error === "User suspended")).toBe(true); + }); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + it("should aggregate all failures when all batches fail", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const errorMessage = "All batches failed"; - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( + new Error(errorMessage), + ); - // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch - // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values - const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1; - const expectedFailuresInBatch2 = 2; - const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2; - const expectedSuccesses = totalUsers - expectedTotalFailures; + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(expectedSuccesses); - expect(result.failed).toHaveLength(expectedTotalFailures); - expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); - expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); - expect(result.failed.some((f) => f.error === "User suspended")).toBe(true); - }); + expect(result.successful).toBeUndefined(); + expect(result.failed).toHaveLength(totalUsers); + expect(result.failed.every((f) => f.error === errorMessage)).toBe(true); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); + }); - it("should aggregate all failures when all batches fail", async () => { - const totalUsers = REQUESTS_PER_BATCH + 100; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - const errorMessage = "All batches failed"; + it("should handle empty data in batch response", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( - new Error(errorMessage), - ); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: [], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeUndefined(); - expect(result.failed).toHaveLength(totalUsers); - expect(result.failed.every((f) => f.error === errorMessage)).toBe(true); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should handle empty data in batch response", async () => { - const totalUsers = REQUESTS_PER_BATCH + 50; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + }); - const mockResponse2 = new ListResponse( - { - data: [], - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should process batches sequentially in order", async () => { + const totalUsers = REQUESTS_PER_BATCH * 2; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const callOrder: number[] = []; - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( + async (orgId, ids) => { + const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2; + callOrder.push(batchIndex); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + return new ListResponse( + { + data: ids.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + }, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); - expect(result.failed).toHaveLength(0); - }); + await service.bulkReinvite(mockOrganization, userIdsBatch); - it("should process batches sequentially in order", async () => { - const totalUsers = REQUESTS_PER_BATCH * 2; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - const callOrder: number[] = []; - - organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( - async (orgId, ids) => { - const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2; - callOrder.push(batchIndex); - - return new ListResponse( - { - data: ids.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); - }, - ); - - await service.bulkReinvite(mockOrganization, userIdsBatch); - - expect(callOrder).toEqual([1, 2]); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - }); + expect(callOrder).toEqual([1, 2]); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 5833238209c..e8c4a21d675 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -16,9 +16,7 @@ import { import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogService } from "@bitwarden/components"; @@ -45,7 +43,6 @@ export interface BulkActionResult { export class MemberActionsService { private organizationUserApiService = inject(OrganizationUserApiService); private organizationUserService = inject(OrganizationUserService); - private configService = inject(ConfigService); private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); private apiService = inject(ApiService); private dialogService = inject(DialogService); @@ -175,18 +172,9 @@ export class MemberActionsService { async bulkReinvite(organization: Organization, userIds: UserId[]): Promise { this.startProcessing(); try { - const increaseBulkReinviteLimitForCloud = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => + this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), ); - if (increaseBulkReinviteLimitForCloud) { - return await this.vNextBulkReinvite(organization, userIds); - } else { - const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( - organization.id, - userIds, - ); - return { successful: result, failed: [] }; - } } catch (error) { return { failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), @@ -196,15 +184,6 @@ export class MemberActionsService { } } - async vNextBulkReinvite( - organization: Organization, - userIds: UserId[], - ): Promise { - return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => - this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), - ); - } - allowResetPassword( orgUser: OrganizationUserView, organization: Organization, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts index 004b0a8f7c9..3c6e530b686 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts @@ -19,7 +19,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -85,7 +84,6 @@ export class MembersComponent extends BaseMembersComponent { private providerService: ProviderService, private router: Router, private accountService: AccountService, - private configService: ConfigService, private environmentService: EnvironmentService, ) { super( @@ -100,7 +98,7 @@ export class MembersComponent extends BaseMembersComponent { toastService, ); - this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + this.dataSource = new MembersTableDataSource(this.environmentService); combineLatest([ this.activatedRoute.parent.params, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index d02b44af1be..a2330be4c6f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -21,7 +21,6 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -72,7 +71,6 @@ export class vNextMembersComponent { private activatedRoute = inject(ActivatedRoute); private providerService = inject(ProviderService); private accountService = inject(AccountService); - private configService = inject(ConfigService); private environmentService = inject(EnvironmentService); private providerActionsService = inject(ProviderActionsService); private memberActionsService = inject(MemberActionsService); @@ -94,7 +92,7 @@ export class vNextMembersComponent { protected statusToggle = new BehaviorSubject(undefined); protected readonly dataSource: WritableSignal = signal( - new ProvidersTableDataSource(this.configService, this.environmentService), + new ProvidersTableDataSource(this.environmentService), ); protected readonly firstLoaded: WritableSignal = signal(false); @@ -177,7 +175,7 @@ export class vNextMembersComponent { // Capture the original count BEFORE enforcing the limit const originalInvitedCount = allInvitedUsers.length; - // When feature flag is enabled, limit invited users and uncheck the excess + // In cloud environments, limit invited users and uncheck the excess let checkedInvitedUsers: ProviderUser[]; if (this.dataSource().isIncreasedBulkLimitEnabled()) { checkedInvitedUsers = this.dataSource().limitAndUncheckExcess( @@ -198,7 +196,7 @@ export class vNextMembersComponent { } try { - // When feature flag is enabled, show toast instead of dialog + // In cloud environments, show toast instead of dialog if (this.dataSource().isIncreasedBulkLimitEnabled()) { await this.apiService.postManyProviderUserReinvite( providerId, @@ -226,7 +224,7 @@ export class vNextMembersComponent { }); } } else { - // Feature flag disabled - show legacy dialog + // In self-hosted environments, show legacy dialog const request = this.apiService.postManyProviderUserReinvite( providerId, new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)), diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 35fa520f34a..244bd80d1fa 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -13,7 +13,6 @@ export enum FeatureFlag { /* Admin Console Team */ AutoConfirm = "pm-19934-auto-confirm-organization-users", BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration", - IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud", MembersComponentRefactor = "pm-29503-refactor-members-inheritance", /* Auth */ @@ -104,7 +103,6 @@ export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AutoConfirm]: FALSE, [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, - [FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE, [FeatureFlag.MembersComponentRefactor]: FALSE, /* Autofill */ From 65b224646d87daa4794773cce5eefc815b71bd69 Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Wed, 28 Jan 2026 09:32:02 -0500 Subject: [PATCH 03/13] Tools/pm 29918/implement send auth flows (#18270) * [PM-29918] Implement new Send auth flows * [PM-29918] Fix types * Trigger Claude code review * [PM-29918] Address PR review comments * [PM-29918] Remove duplicate AuthType const --- .../send/send-access/access.component.html | 3 +- .../send/send-access/access.component.ts | 37 ++-- .../send-access-email.component.html | 35 +++ .../send-access-email.component.ts | 35 +++ .../send-access-file.component.html | 4 +- .../send-access/send-access-file.component.ts | 40 ++-- .../send-access-password.component.html | 41 ++-- .../send-access-password.component.ts | 35 +-- .../send/send-access/send-auth.component.html | 48 +++-- .../send/send-access/send-auth.component.ts | 203 ++++++++++++++---- .../send/send-access/send-view.component.html | 83 +++---- .../send/send-access/send-view.component.ts | 97 +++++---- apps/web/src/locales/en/messages.json | 3 + .../services/send-api.service.abstraction.ts | 11 + .../tools/send/services/send-api.service.ts | 41 ++++ 15 files changed, 493 insertions(+), 223 deletions(-) create mode 100644 apps/web/src/app/tools/send/send-access/send-access-email.component.html create mode 100644 apps/web/src/app/tools/send/send-access/send-access-email.component.ts diff --git a/apps/web/src/app/tools/send/send-access/access.component.html b/apps/web/src/app/tools/send/send-access/access.component.html index b86933410b8..6cda4cf4d7d 100644 --- a/apps/web/src/app/tools/send/send-access/access.component.html +++ b/apps/web/src/app/tools/send/send-access/access.component.html @@ -1,4 +1,4 @@ -@switch (viewState) { +@switch (viewState()) { @case ("auth") { } @@ -6,6 +6,7 @@ (SendViewState.Auth); id: string; key: string; + sendAccessToken: SendAccessToken | null = null; sendAccessResponse: SendAccessResponse | null = null; sendAccessRequest: SendAccessRequest = new SendAccessRequest(); - constructor(private route: ActivatedRoute) {} + constructor( + private route: ActivatedRoute, + private destroyRef: DestroyRef, + ) {} - async ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.params.subscribe(async (params) => { + ngOnInit() { + this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { this.id = params.sendId; this.key = params.key; - - if (this.id && this.key) { - this.viewState = SendViewState.View; - this.sendAccessResponse = null; - this.sendAccessRequest = new SendAccessRequest(); - } }); } onAuthRequired() { - this.viewState = SendViewState.Auth; + this.viewState.set(SendViewState.Auth); } - onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) { + onAccessGranted(event: { + response?: SendAccessResponse; + request?: SendAccessRequest; + accessToken?: SendAccessToken; + }) { this.sendAccessResponse = event.response; this.sendAccessRequest = event.request; - this.viewState = SendViewState.View; + this.sendAccessToken = event.accessToken; + this.viewState.set(SendViewState.View); } } diff --git a/apps/web/src/app/tools/send/send-access/send-access-email.component.html b/apps/web/src/app/tools/send/send-access/send-access-email.component.html new file mode 100644 index 00000000000..ee5a03670bb --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-email.component.html @@ -0,0 +1,35 @@ +@if (!enterOtp()) { + + {{ "email" | i18n }} + + +
+ +
+} @else { + + {{ "verificationCode" | i18n }} + + +
+ +
+} diff --git a/apps/web/src/app/tools/send/send-access/send-access-email.component.ts b/apps/web/src/app/tools/send/send-access/send-access-email.component.ts new file mode 100644 index 00000000000..b1374cd6c66 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-email.component.ts @@ -0,0 +1,35 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; + +import { SharedModule } from "../../../shared"; + +@Component({ + selector: "app-send-access-email", + templateUrl: "send-access-email.component.html", + imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendAccessEmailComponent implements OnInit, OnDestroy { + protected readonly formGroup = input.required(); + protected readonly enterOtp = input.required(); + protected email: FormControl; + protected otp: FormControl; + + readonly loading = input.required(); + + constructor() {} + + ngOnInit() { + this.email = new FormControl("", Validators.required); + this.otp = new FormControl("", Validators.required); + this.formGroup().addControl("email", this.email); + this.formGroup().addControl("otp", this.otp); + } + + ngOnDestroy() { + this.formGroup().removeControl("email"); + this.formGroup().removeControl("otp"); + } +} diff --git a/apps/web/src/app/tools/send/send-access/send-access-file.component.html b/apps/web/src/app/tools/send/send-access/send-access-file.component.html index 8cbe6a975ef..4088b3a7034 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-file.component.html +++ b/apps/web/src/app/tools/send/send-access/send-access-file.component.html @@ -1,5 +1,5 @@ -

{{ send.file.fileName }}

+

{{ send().file.fileName }}

diff --git a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts index dc7689f011a..bb45e83d110 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts @@ -1,8 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -15,40 +18,39 @@ import { ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-access-file", templateUrl: "send-access-file.component.html", imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendAccessFileComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() send: SendAccessView; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() decKey: SymmetricCryptoKey; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() accessRequest: SendAccessRequest; + readonly send = input(null); + readonly decKey = input(null); + readonly accessRequest = input(null); + readonly accessToken = input(null); + constructor( private i18nService: I18nService, private toastService: ToastService, private encryptService: EncryptService, private fileDownloadService: FileDownloadService, private sendApiService: SendApiService, + private configService: ConfigService, ) {} protected download = async () => { - if (this.send == null || this.decKey == null) { + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + const accessToken = this.accessToken(); + const accessRequest = this.accessRequest(); + const authMissing = (sendEmailOtp && !accessToken) || (!sendEmailOtp && !accessRequest); + if (this.send() == null || this.decKey() == null || authMissing) { return; } - const downloadData = await this.sendApiService.getSendFileDownloadData( - this.send, - this.accessRequest, - ); + const downloadData = sendEmailOtp + ? await this.sendApiService.getSendFileDownloadDataV2(this.send(), accessToken) + : await this.sendApiService.getSendFileDownloadData(this.send(), accessRequest); if (Utils.isNullOrWhitespace(downloadData.url)) { this.toastService.showToast({ @@ -71,9 +73,9 @@ export class SendAccessFileComponent { try { const encBuf = await EncArrayBuffer.fromResponse(response); - const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey); + const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey()); this.fileDownloadService.download({ - fileName: this.send.file.fileName, + fileName: this.send().file.fileName, blobData: decBuf, downloadMethod: "save", }); diff --git a/apps/web/src/app/tools/send/send-access/send-access-password.component.html b/apps/web/src/app/tools/send/send-access/send-access-password.component.html index 8bb2c306010..deca7ad3d24 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-password.component.html +++ b/apps/web/src/app/tools/send/send-access/send-access-password.component.html @@ -1,28 +1,19 @@

{{ "sendProtectedPassword" | i18n }}

{{ "sendProtectedPasswordDontKnow" | i18n }}

-
- - {{ "password" | i18n }} - - - -
- -
+ + {{ "password" | i18n }} + + + +
+
diff --git a/apps/web/src/app/tools/send/send-access/send-access-password.component.ts b/apps/web/src/app/tools/send/send-access/send-access-password.component.ts index 34b183be10e..b2ee222ae86 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-password.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-password.component.ts @@ -1,43 +1,30 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { Subject, takeUntil } from "rxjs"; +import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; import { SharedModule } from "../../../shared"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-access-password", templateUrl: "send-access-password.component.html", imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendAccessPasswordComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - protected formGroup = this.formBuilder.group({ - password: ["", [Validators.required]], - }); + protected readonly formGroup = input.required(); + protected password: FormControl; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() loading: boolean; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() setPasswordEvent = new EventEmitter(); + readonly loading = input.required(); - constructor(private formBuilder: FormBuilder) {} + constructor() {} - async ngOnInit() { - this.formGroup.controls.password.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe((val) => { - this.setPasswordEvent.emit(val); - }); + ngOnInit() { + this.password = new FormControl("", Validators.required); + this.formGroup().addControl("password", this.password); } ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); + this.formGroup().removeControl("password"); } } diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.html b/apps/web/src/app/tools/send/send-access/send-auth.component.html index 21a6de50ba8..c3e90cea4ea 100644 --- a/apps/web/src/app/tools/send/send-access/send-auth.component.html +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.html @@ -1,14 +1,38 @@ -
-
-

{{ "sendAccessUnavailable" | i18n }}

+@if (loading()) { +
+ + {{ "loading" | i18n }}
-
-

{{ "unexpectedErrorSend" | i18n }}

-
- - +} + + @if (error()) { +
+

{{ "unexpectedErrorSend" | i18n }}

+
+ } + @if (unavailable()) { +
+

{{ "sendAccessUnavailable" | i18n }}

+
+ } @else { + @switch (sendAuthType()) { + @case (authType.Password) { + + } + @case (authType.Email) { + + } + } + } diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.ts b/apps/web/src/app/tools/send/send-access/send-auth.component.ts index b360044a8b6..13e82bd4cfa 100644 --- a/apps/web/src/app/tools/send/send-access/send-auth.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.ts @@ -1,86 +1,211 @@ -import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, OnInit, output, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; +import { + emailAndOtpRequiredEmailSent, + emailInvalid, + emailRequired, + otpInvalid, + passwordHashB64Invalid, + passwordHashB64Required, + SendAccessDomainCredentials, + SendAccessToken, + SendHashedPasswordB64, + sendIdInvalid, + SendOtp, + SendTokenService, +} from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; +import { SendAccessEmailComponent } from "./send-access-email.component"; import { SendAccessPasswordComponent } from "./send-access-password.component"; @Component({ selector: "app-send-auth", templateUrl: "send-auth.component.html", - imports: [SendAccessPasswordComponent, SharedModule], + imports: [SendAccessPasswordComponent, SendAccessEmailComponent, SharedModule], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SendAuthComponent { - readonly id = input.required(); - readonly key = input.required(); +export class SendAuthComponent implements OnInit { + protected readonly id = input.required(); + protected readonly key = input.required(); - accessGranted = output<{ - response: SendAccessResponse; - request: SendAccessRequest; + protected accessGranted = output<{ + response?: SendAccessResponse; + request?: SendAccessRequest; + accessToken?: SendAccessToken; }>(); - loading = false; - error = false; - unavailable = false; - password?: string; + authType = AuthType; - private accessRequest!: SendAccessRequest; + private expiredAuthAttempts = 0; + + readonly loading = signal(false); + readonly error = signal(false); + readonly unavailable = signal(false); + readonly sendAuthType = signal(AuthType.None); + readonly enterOtp = signal(false); + + sendAccessForm = this.formBuilder.group<{ password?: string; email?: string; otp?: string }>({}); constructor( private cryptoFunctionService: CryptoFunctionService, private sendApiService: SendApiService, private toastService: ToastService, private i18nService: I18nService, + private formBuilder: FormBuilder, + private configService: ConfigService, + private sendTokenService: SendTokenService, ) {} - async onSubmit(password: string) { - this.password = password; - this.loading = true; - this.error = false; - this.unavailable = false; + ngOnInit() { + void this.onSubmit(); + } + async onSubmit() { + this.loading.set(true); + this.unavailable.set(false); + this.error.set(false); + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + if (sendEmailOtp) { + await this.attemptV2Access(); + } else { + await this.attemptV1Access(); + } + this.loading.set(false); + } + + private async attemptV1Access() { try { - const keyArray = Utils.fromUrlB64ToArray(this.key()); - this.accessRequest = new SendAccessRequest(); - - const passwordHash = await this.cryptoFunctionService.pbkdf2( - this.password, - keyArray, - "sha256", - SEND_KDF_ITERATIONS, - ); - this.accessRequest.password = Utils.fromBufferToB64(passwordHash); - - const sendResponse = await this.sendApiService.postSendAccess(this.id(), this.accessRequest); - this.accessGranted.emit({ response: sendResponse, request: this.accessRequest }); + const accessRequest = new SendAccessRequest(); + if (this.sendAuthType() === AuthType.Password) { + const password = this.sendAccessForm.value.password; + if (password == null) { + return; + } + accessRequest.password = await this.getPasswordHashB64(password, this.key()); + } + const sendResponse = await this.sendApiService.postSendAccess(this.id(), accessRequest); + this.accessGranted.emit({ request: accessRequest, response: sendResponse }); } catch (e) { if (e instanceof ErrorResponse) { - if (e.statusCode === 404) { - this.unavailable = true; - } else if (e.statusCode === 400) { + if (e.statusCode === 401) { + this.sendAuthType.set(AuthType.Password); + } else if (e.statusCode === 404) { + this.unavailable.set(true); + } else { + this.error.set(true); this.toastService.showToast({ variant: "error", title: this.i18nService.t("errorOccurred"), message: e.message, }); - } else { - this.error = true; } } else { - this.error = true; + this.error.set(true); } - } finally { - this.loading = false; } } + + private async attemptV2Access(): Promise { + let sendAccessCreds: SendAccessDomainCredentials | null = null; + if (this.sendAuthType() === AuthType.Email) { + const email = this.sendAccessForm.value.email; + if (email == null) { + return; + } + if (!this.enterOtp()) { + sendAccessCreds = { kind: "email", email }; + } else { + const otp = this.sendAccessForm.value.otp as SendOtp; + if (otp == null) { + return; + } + sendAccessCreds = { kind: "email_otp", email, otp }; + } + } else if (this.sendAuthType() === AuthType.Password) { + const password = this.sendAccessForm.value.password; + if (password == null) { + return; + } + const passwordHashB64 = await this.getPasswordHashB64(password, this.key()); + sendAccessCreds = { kind: "password", passwordHashB64 }; + } + const response = !sendAccessCreds + ? await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(this.id())) + : await firstValueFrom(this.sendTokenService.getSendAccessToken$(this.id(), sendAccessCreds)); + if (response instanceof SendAccessToken) { + this.expiredAuthAttempts = 0; + this.accessGranted.emit({ accessToken: response }); + } else if (response.kind === "expired") { + if (this.expiredAuthAttempts > 2) { + return; + } + this.expiredAuthAttempts++; + await this.attemptV2Access(); + } else if (response.kind === "expected_server") { + this.expiredAuthAttempts = 0; + if (emailRequired(response.error)) { + this.sendAuthType.set(AuthType.Email); + } else if (emailAndOtpRequiredEmailSent(response.error) || emailInvalid(response.error)) { + this.enterOtp.set(true); + } else if (otpInvalid(response.error)) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidVerificationCode"), + }); + } else if (passwordHashB64Required(response.error)) { + this.sendAuthType.set(AuthType.Password); + } else if (passwordHashB64Invalid(response.error)) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidSendPassword"), + }); + } else if (sendIdInvalid(response.error)) { + this.unavailable.set(true); + } else { + this.error.set(true); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: response.error.error_description ?? "", + }); + } + } else { + this.expiredAuthAttempts = 0; + this.error.set(true); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: response.error, + }); + } + } + + private async getPasswordHashB64(password: string, key: string) { + const keyArray = Utils.fromUrlB64ToArray(key); + const passwordHash = await this.cryptoFunctionService.pbkdf2( + password, + keyArray, + "sha256", + SEND_KDF_ITERATIONS, + ); + return Utils.fromBufferToB64(passwordHash) as SendHashedPasswordB64; + } } diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.html b/apps/web/src/app/tools/send/send-access/send-view.component.html index dd0b770b261..3536499ddad 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.html +++ b/apps/web/src/app/tools/send/send-access/send-view.component.html @@ -1,41 +1,13 @@ - - {{ "viewSendHiddenEmailWarning" | i18n }} - {{ - "learnMore" | i18n - }}. - +@if (hideEmail()) { + + {{ "viewSendHiddenEmailWarning" | i18n }} + {{ + "learnMore" | i18n + }} + +} - -
-

{{ "sendAccessUnavailable" | i18n }}

-
-
-

{{ "unexpectedErrorSend" | i18n }}

-
-
-

- {{ send.name }} -

-
- - - - - - - - -

- Expires: {{ expirationDate | date: "medium" }} -

-
-
- +@if (loading()) {
{{ "loading" | i18n }}
-
+} @else { + @if (unavailable()) { +
+

{{ "sendAccessUnavailable" | i18n }}

+
+ } + @if (error()) { +
+

{{ "unexpectedErrorSend" | i18n }}

+
+ } + @if (send()) { +
+

+ {{ send().name }} +

+
+ @switch (send().type) { + @case (sendType.Text) { + + } + @case (sendType.File) { + + } + } + @if (expirationDate()) { +

Expires: {{ expirationDate() | date: "medium" }}

+ } +
+ } +} diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts index 060dc1958b1..1ab9a121ace 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts @@ -1,13 +1,17 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, + computed, input, OnInit, output, + signal, } from "@angular/core"; +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; @@ -34,17 +38,25 @@ import { SendAccessTextComponent } from "./send-access-text.component"; export class SendViewComponent implements OnInit { readonly id = input.required(); readonly key = input.required(); + readonly accessToken = input(null); readonly sendResponse = input(null); readonly accessRequest = input(new SendAccessRequest()); authRequired = output(); - send: SendAccessView | null = null; + readonly send = signal(null); + readonly expirationDate = computed(() => this.send()?.expirationDate ?? null); + readonly creatorIdentifier = computed( + () => this.send()?.creatorIdentifier ?? null, + ); + readonly hideEmail = computed( + () => this.send() != null && this.creatorIdentifier() == null, + ); + readonly loading = signal(false); + readonly unavailable = signal(false); + readonly error = signal(false); + sendType = SendType; - loading = true; - unavailable = false; - error = false; - hideEmail = false; decKey!: SymmetricCryptoKey; constructor( @@ -53,50 +65,48 @@ export class SendViewComponent implements OnInit { private toastService: ToastService, private i18nService: I18nService, private layoutWrapperDataService: AnonLayoutWrapperDataService, - private cdRef: ChangeDetectorRef, + private configService: ConfigService, ) {} - get expirationDate() { - if (this.send == null || this.send.expirationDate == null) { - return null; - } - return this.send.expirationDate; - } - - get creatorIdentifier() { - if (this.send == null || this.send.creatorIdentifier == null) { - return null; - } - return this.send.creatorIdentifier; - } - - async ngOnInit() { - await this.load(); + ngOnInit() { + void this.load(); } private async load() { - this.unavailable = false; - this.error = false; - this.hideEmail = false; - this.loading = true; - - let response = this.sendResponse(); + this.loading.set(true); + this.unavailable.set(false); + this.error.set(false); try { - if (!response) { - response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest()); + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + let response: SendAccessResponse; + if (sendEmailOtp) { + const accessToken = this.accessToken(); + if (!accessToken) { + this.authRequired.emit(); + return; + } + response = await this.sendApiService.postSendAccessV2(accessToken); + } else { + const sendResponse = this.sendResponse(); + if (!sendResponse) { + this.authRequired.emit(); + return; + } + response = sendResponse; } - const keyArray = Utils.fromUrlB64ToArray(this.key()); const sendAccess = new SendAccess(response); this.decKey = await this.keyService.makeSendKey(keyArray); - this.send = await sendAccess.decrypt(this.decKey); + const decSend = await sendAccess.decrypt(this.decKey); + this.send.set(decSend); } catch (e) { + this.send.set(null); if (e instanceof ErrorResponse) { if (e.statusCode === 401) { this.authRequired.emit(); } else if (e.statusCode === 404) { - this.unavailable = true; + this.unavailable.set(true); } else if (e.statusCode === 400) { this.toastService.showToast({ variant: "error", @@ -104,28 +114,23 @@ export class SendViewComponent implements OnInit { message: e.message, }); } else { - this.error = true; + this.error.set(true); } } else { - this.error = true; + this.error.set(true); } + } finally { + this.loading.set(false); } - this.loading = false; - this.hideEmail = - this.creatorIdentifier == null && !this.loading && !this.unavailable && !response; - - this.hideEmail = this.send != null && this.creatorIdentifier == null; - - if (this.creatorIdentifier != null) { + const creatorIdentifier = this.creatorIdentifier(); + if (creatorIdentifier != null) { this.layoutWrapperDataService.setAnonLayoutWrapperData({ pageSubtitle: { key: "sendAccessCreatorIdentifier", - placeholders: [this.creatorIdentifier], + placeholders: [creatorIdentifier], }, }); } - - this.cdRef.markForCheck(); } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 932b58cf22a..a01e0b91e71 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12699,5 +12699,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts index 80c4410af11..a7e36d8c8b1 100644 --- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts @@ -1,3 +1,5 @@ +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; + import { ListResponse } from "../../../models/response/list.response"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { Send } from "../models/domain/send"; @@ -16,6 +18,10 @@ export abstract class SendApiService { request: SendAccessRequest, apiUrl?: string, ): Promise; + abstract postSendAccessV2( + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise; abstract getSends(): Promise>; abstract postSend(request: SendRequest): Promise; abstract postFileTypeSend(request: SendRequest): Promise; @@ -28,6 +34,11 @@ export abstract class SendApiService { request: SendAccessRequest, apiUrl?: string, ): Promise; + abstract getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise; abstract renewSendFileUploadUrl( sendId: string, fileId: string, diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index 1c931b7ad98..f09117316d8 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -1,3 +1,5 @@ +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; + import { ApiService } from "../../../abstractions/api.service"; import { ErrorResponse } from "../../../models/response/error.response"; import { ListResponse } from "../../../models/response/list.response"; @@ -52,6 +54,25 @@ export class SendApiService implements SendApiServiceAbstraction { return new SendAccessResponse(r); } + async postSendAccessV2( + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + const setAuthTokenHeader = (headers: Headers) => { + headers.set("Authorization", "Bearer " + accessToken.token); + }; + const r = await this.apiService.send( + "POST", + "/sends/access", + null, + false, + true, + apiUrl, + setAuthTokenHeader, + ); + return new SendAccessResponse(r); + } + async getSendFileDownloadData( send: SendAccessView, request: SendAccessRequest, @@ -72,6 +93,26 @@ export class SendApiService implements SendApiServiceAbstraction { return new SendFileDownloadDataResponse(r); } + async getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + const setAuthTokenHeader = (headers: Headers) => { + headers.set("Authorization", "Bearer " + accessToken.token); + }; + const r = await this.apiService.send( + "POST", + "/sends/access/file/" + send.file.id, + null, + true, + true, + apiUrl, + setAuthTokenHeader, + ); + return new SendFileDownloadDataResponse(r); + } + async getSends(): Promise> { const r = await this.apiService.send("GET", "/sends", null, true, true); return new ListResponse(r, SendResponse); From 0138abf373fb0075207ee07bac071a9a47a4233d Mon Sep 17 00:00:00 2001 From: bmbitwarden Date: Wed, 28 Jan 2026 09:39:37 -0500 Subject: [PATCH 04/13] PM-29919 email verification on sends (#18260) * PM-29919 email verification on sends * PM-29919 resolved build issue * PM-29919 refined who can view fields * PM-29919 resolved lint issues * PM-29919 resolved lint issues * PM-29919 resolved unit tests * PM-29919 resolved lint issues * PM-29919 resolved unit test issue * PM-29919 resolved pr comments * PM-29919 resolved pr comments * PM-29919 resolved unneeded label * PM-29919 refactored to hide instead of disable * PM-29919 resolved pr comments * PM-29919 resolved no auth string in PM-31200 * PM-29919 resolved bugs --- apps/browser/src/_locales/en/messages.json | 48 +++-- apps/web/src/locales/en/messages.json | 24 +++ .../options/send-options.component.html | 72 ++----- .../options/send-options.component.spec.ts | 19 -- .../options/send-options.component.ts | 103 ++-------- .../send-details/send-details.component.html | 78 +++++++- .../send-details.component.spec.ts | 105 +++++++++- .../send-details/send-details.component.ts | 187 +++++++++++++++++- 8 files changed, 447 insertions(+), 189 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 8e2c3279687..4c36a852f6a 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -4610,11 +4619,11 @@ "message": "URI match detection is how Bitwarden identifies autofill suggestions.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, - "regExAdvancedOptionWarning": { + "regExAdvancedOptionWarning": { "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, - "startsWithAdvancedOptionWarning": { + "startsWithAdvancedOptionWarning": { "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, @@ -4622,7 +4631,7 @@ "message": "More about match detection", "description": "Link to match detection docs on warning dialog for advance match strategy" }, - "uriAdvancedOption":{ + "uriAdvancedOption": { "message": "Advanced options", "description": "Advanced option placeholder for uri option component" }, @@ -4812,7 +4821,7 @@ } } }, - "copyFieldCipherName": { + "copyFieldCipherName": { "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { @@ -4844,7 +4853,7 @@ "adminConsole": { "message": "Admin Console" }, - "admin" :{ + "admin": { "message": "Admin" }, "automaticUserConfirmation": { @@ -4853,7 +4862,7 @@ "automaticUserConfirmationHint": { "message": "Automatically confirm pending users while this device is unlocked" }, - "autoConfirmOnboardingCallout":{ + "autoConfirmOnboardingCallout": { "message": "Save time with automatic user confirmation" }, "autoConfirmWarning": { @@ -5793,7 +5802,7 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitleV2":{ + "phishingPageTitleV2": { "message": "Phishing attempt detected" }, "phishingPageSummary": { @@ -5813,7 +5822,7 @@ "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, - "phishingPageLearnMore" : { + "phishingPageLearnMore": { "message": "Learn more about phishing detection" }, "protectedBy": { @@ -5981,7 +5990,7 @@ "cardNumberLabel": { "message": "Card number" }, - "removeMasterPasswordForOrgUserKeyConnector":{ + "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { @@ -5999,10 +6008,10 @@ "verifyYourOrganization": { "message": "Verify your organization to log in" }, - "organizationVerified":{ + "organizationVerified": { "message": "Organization verified" }, - "domainVerified":{ + "domainVerified": { "message": "Domain verified" }, "leaveOrganizationContent": { @@ -6120,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a01e0b91e71..3ba1ffc910b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -586,6 +586,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1368,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -12691,6 +12700,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index a271788b0ef..3f28ed289c9 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -7,64 +7,22 @@ {{ "limitSendViews" | i18n }} {{ "limitSendViewsHint" | i18n }} -  {{ "limitSendViewsCount" | i18n: viewsLeft }} + @if (shouldShowCount) { +  {{ "limitSendViewsCount" | i18n: viewsLeft }} + } - - {{ (passwordRemoved ? "newPassword" : "password") | i18n }} - - - - - - {{ "sendPasswordDescV3" | i18n }} - - - - {{ "hideYourEmail" | i18n }} - + + @if (!disableHideEmail || originalSendView?.hideEmail) { + + + {{ "hideYourEmail" | i18n }} + + } {{ "privateNote" | i18n }} diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts index fa069b92ed2..47e8403f770 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts @@ -5,12 +5,7 @@ import { of } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { SendType } from "@bitwarden/common/tools/send/types/send-type"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { CredentialGeneratorService } from "@bitwarden/generator-core"; import { SendFormContainer } from "../../send-form-container"; @@ -32,14 +27,9 @@ describe("SendOptionsComponent", () => { declarations: [], providers: [ { provide: SendFormContainer, useValue: mockSendFormContainer }, - { provide: DialogService, useValue: mock() }, - { provide: SendApiService, useValue: mock() }, { provide: PolicyService, useValue: mock() }, { provide: I18nService, useValue: mock() }, - { provide: ToastService, useValue: mock() }, - { provide: CredentialGeneratorService, useValue: mock() }, { provide: AccountService, useValue: mockAccountService }, - { provide: PlatformUtilsService, useValue: mock() }, ], }).compileComponents(); fixture = TestBed.createComponent(SendOptionsComponent); @@ -55,13 +45,4 @@ describe("SendOptionsComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); - - it("should emit a null password when password textbox is empty", async () => { - const newSend = {} as SendView; - mockSendFormContainer.patchSend.mockImplementation((updateFn) => updateFn(newSend)); - component.sendOptionsForm.patchValue({ password: "testing" }); - expect(newSend.password).toBe("testing"); - component.sendOptionsForm.patchValue({ password: "" }); - expect(newSend.password).toBe(null); - }); }); diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index ae8706a375e..a5f369d66aa 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -4,32 +4,26 @@ import { CommonModule } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { BehaviorSubject, firstValueFrom, map, switchMap, tap } from "rxjs"; +import { switchMap, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; 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 { Utils } from "@bitwarden/common/platform/misc/utils"; -import { pin } from "@bitwarden/common/tools/rx"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { + TypographyModule, AsyncActionsModule, ButtonModule, CardComponent, CheckboxModule, - DialogService, FormFieldModule, IconButtonModule, SectionComponent, SectionHeaderComponent, - ToastService, - TypographyModule, + SelectModule, } from "@bitwarden/components"; -import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -39,6 +33,7 @@ import { SendFormContainer } from "../../send-form-container"; @Component({ selector: "tools-send-options", templateUrl: "./send-options.component.html", + standalone: true, imports: [ AsyncActionsModule, ButtonModule, @@ -51,6 +46,7 @@ import { SendFormContainer } from "../../send-form-container"; ReactiveFormsModule, SectionComponent, SectionHeaderComponent, + SelectModule, TypographyModule, ], }) @@ -64,19 +60,14 @@ export class SendOptionsComponent implements OnInit { @Input() originalSendView: SendView; disableHideEmail = false; - passwordRemoved = false; + sendOptionsForm = this.formBuilder.group({ maxAccessCount: [null as number], accessCount: [null as number], notes: [null as string], - password: [null as string], hideEmail: [false as boolean], }); - get hasPassword(): boolean { - return this.originalSendView && this.originalSendView.password !== null; - } - get shouldShowCount(): boolean { return this.config.mode === "edit" && this.sendOptionsForm.value.maxAccessCount !== null; } @@ -91,13 +82,8 @@ export class SendOptionsComponent implements OnInit { constructor( private sendFormContainer: SendFormContainer, - private dialogService: DialogService, - private sendApiService: SendApiService, private formBuilder: FormBuilder, private policyService: PolicyService, - private i18nService: I18nService, - private toastService: ToastService, - private generatorService: CredentialGeneratorService, private accountService: AccountService, ) { this.sendFormContainer.registerChildForm("sendOptionsForm", this.sendOptionsForm); @@ -113,87 +99,28 @@ export class SendOptionsComponent implements OnInit { this.disableHideEmail = disableHideEmail; }); - this.sendOptionsForm.valueChanges - .pipe( - tap((value) => { - if (Utils.isNullOrWhitespace(value.password)) { - value.password = null; - } - }), - takeUntilDestroyed(), - ) - .subscribe((value) => { - this.sendFormContainer.patchSend((send) => { - Object.assign(send, { - maxAccessCount: value.maxAccessCount, - accessCount: value.accessCount, - password: value.password, - hideEmail: value.hideEmail, - notes: value.notes, - }); - return send; + this.sendOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + Object.assign(send, { + maxAccessCount: value.maxAccessCount, + accessCount: value.accessCount, + hideEmail: value.hideEmail, + notes: value.notes, }); + return send; }); + }); } - generatePassword = async () => { - const on$ = new BehaviorSubject({ source: "send", type: Type.password }); - const account$ = this.accountService.activeAccount$.pipe( - pin({ name: () => "send-options.component", distinct: (p, c) => p.id === c.id }), - ); - const generatedCredential = await firstValueFrom( - this.generatorService.generate$({ on$, account$ }), - ); - - this.sendOptionsForm.patchValue({ - password: generatedCredential.credential, - }); - }; - - removePassword = async () => { - if (!this.originalSendView || !this.originalSendView.password) { - return; - } - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "removePassword" }, - content: { key: "removePasswordConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - this.passwordRemoved = true; - - await this.sendApiService.removePassword(this.originalSendView.id); - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("removedPassword"), - }); - - this.originalSendView.password = null; - this.sendOptionsForm.patchValue({ - password: null, - }); - this.sendOptionsForm.get("password")?.enable(); - }; - ngOnInit() { if (this.sendFormContainer.originalSendView) { this.sendOptionsForm.patchValue({ maxAccessCount: this.sendFormContainer.originalSendView.maxAccessCount, accessCount: this.sendFormContainer.originalSendView.accessCount, - password: this.hasPassword ? "************" : null, // 12 masked characters as a placeholder hideEmail: this.sendFormContainer.originalSendView.hideEmail, notes: this.sendFormContainer.originalSendView.notes, }); } - if (this.hasPassword) { - this.sendOptionsForm.get("password")?.disable(); - } if (!this.config.areSendsAllowed) { this.sendOptionsForm.disable(); diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index e650ca3a5df..6d42cca2186 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -6,7 +6,7 @@ {{ "name" | i18n }} - + - + {{ "deletionDate" | i18n }} {{ "deletionDateDescV2" | i18n }} + + + {{ "whoCanView" | i18n }} + + @for (option of availableAuthTypes$ | async; track option.value) { + + } + + @if (sendDetailsForm.get("authType").value === AuthType.Email) { + {{ "emailVerificationDesc" | i18n }} + } + + + @if (sendDetailsForm.get("authType").value === AuthType.Password) { + + {{ (passwordRemoved ? "newPassword" : "password") | i18n }} + +
+ @if (!hasPassword) { + + + + } @else { + + } +
+ {{ "sendPasswordDescV3" | i18n }} +
+ } + + @if (sendDetailsForm.get("authType").value === AuthType.Email) { + + {{ "emails" | i18n }} + + {{ "enterMultipleEmailsSeparatedByComma" | i18n }} + + } diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts index 576842cd877..f816c9d5ce4 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts @@ -1,4 +1,29 @@ -import { DatePreset, isDatePreset, asDatePreset } from "./send-details.component"; +import { DatePipe } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { 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 { DialogService, ToastService } from "@bitwarden/components"; +import { CredentialGeneratorService } from "@bitwarden/generator-core"; + +import { SendFormContainer } from "../../send-form-container"; + +import { + DatePreset, + SendDetailsComponent, + asDatePreset, + isDatePreset, +} from "./send-details.component"; describe("SendDetails DatePreset utilities", () => { it("accepts all defined numeric presets", () => { @@ -25,3 +50,81 @@ describe("SendDetails DatePreset utilities", () => { }); }); }); + +describe("SendDetailsComponent", () => { + let component: SendDetailsComponent; + let fixture: ComponentFixture; + const mockSendFormContainer = mock(); + const mockI18nService = mock(); + const mockConfigService = mock(); + const mockAccountService = mock(); + const mockBillingStateService = mock(); + const mockGeneratorService = mock(); + const mockSendApiService = mock(); + const mockEnvironmentService = mock(); + + beforeEach(async () => { + mockEnvironmentService.environment$ = of({ + getSendUrl: () => "https://send.bitwarden.com/", + } as any); + mockAccountService.activeAccount$ = of({ id: "userId" } as Account); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + mockI18nService.t.mockImplementation((k) => k); + + await TestBed.configureTestingModule({ + imports: [SendDetailsComponent, ReactiveFormsModule], + providers: [ + { provide: SendFormContainer, useValue: mockSendFormContainer }, + { provide: I18nService, useValue: mockI18nService }, + { provide: DatePipe, useValue: new DatePipe("en-US") }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: BillingAccountProfileStateService, useValue: mockBillingStateService }, + { provide: CredentialGeneratorService, useValue: mockGeneratorService }, + { provide: SendApiService, useValue: mockSendApiService }, + { provide: PolicyService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SendDetailsComponent); + component = fixture.componentInstance; + component.config = { areSendsAllowed: true, mode: "add", sendType: SendType.Text }; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize authType to None if no password or emails", () => { + expect(component.sendDetailsForm.value.authType).toBe(AuthType.None); + }); + + it("should toggle validation based on authType", () => { + const emailsControl = component.sendDetailsForm.get("emails"); + const passwordControl = component.sendDetailsForm.get("password"); + + // Default + expect(emailsControl?.validator).toBeNull(); + expect(passwordControl?.validator).toBeNull(); + + // Select Email + component.sendDetailsForm.patchValue({ authType: AuthType.Email }); + expect(emailsControl?.validator).not.toBeNull(); + expect(passwordControl?.validator).toBeNull(); + + // Select Password + component.sendDetailsForm.patchValue({ authType: AuthType.Password }); + expect(passwordControl?.validator).not.toBeNull(); + expect(emailsControl?.validator).toBeNull(); + + // Select None + component.sendDetailsForm.patchValue({ authType: AuthType.None }); + expect(emailsControl?.validator).toBeNull(); + expect(passwordControl?.validator).toBeNull(); + }); +}); diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index e2b50eafc99..463f3195645 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -3,13 +3,28 @@ import { CommonModule, DatePipe } from "@angular/common"; import { Component, OnInit, Input } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; +import { + FormBuilder, + FormControl, + ReactiveFormsModule, + Validators, + ValidatorFn, + ValidationErrors, +} from "@angular/forms"; +import { firstValueFrom, BehaviorSubject, combineLatest, map, switchMap, tap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { pin } from "@bitwarden/common/tools/rx"; 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 { SectionComponent, @@ -20,7 +35,12 @@ import { IconButtonModule, CheckboxModule, SelectModule, + AsyncActionsModule, + ButtonModule, + ToastService, + DialogService, } from "@bitwarden/components"; +import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -78,6 +98,7 @@ export function asDatePreset(value: unknown): DatePreset | undefined { @Component({ selector: "tools-send-details", templateUrl: "./send-details.component.html", + standalone: true, imports: [ SectionComponent, SectionHeaderComponent, @@ -92,7 +113,10 @@ export function asDatePreset(value: unknown): DatePreset | undefined { IconButtonModule, CheckboxModule, CommonModule, + CommonModule, SelectModule, + AsyncActionsModule, + ButtonModule, ], }) export class SendDetailsComponent implements OnInit { @@ -105,31 +129,110 @@ export class SendDetailsComponent implements OnInit { FileSendType = SendType.File; TextSendType = SendType.Text; + readonly AuthType = AuthType; sendLink: string | null = null; customDeletionDateOption: DatePresetSelectOption | null = null; datePresetOptions: DatePresetSelectOption[] = []; + passwordRemoved = false; + + emailVerificationFeatureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.SendEmailOTP); + hasPremium$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + ); + + authTypes: { name: string; value: AuthType; disabled?: boolean }[] = [ + { name: this.i18nService.t("noAuth"), value: AuthType.None }, + { name: this.i18nService.t("specificPeople"), value: AuthType.Email }, + { name: this.i18nService.t("anyOneWithPassword"), value: AuthType.Password }, + ]; + + availableAuthTypes$ = combineLatest([this.emailVerificationFeatureFlag$, this.hasPremium$]).pipe( + map(([enabled, hasPremium]) => { + if (!enabled || !hasPremium) { + return this.authTypes.filter((t) => t.value !== AuthType.Email); + } + return this.authTypes; + }), + ); sendDetailsForm = this.formBuilder.group({ name: new FormControl("", Validators.required), selectedDeletionDatePreset: new FormControl(DatePreset.SevenDays || "", Validators.required), + authType: [AuthType.None as AuthType], + password: [null as string], + emails: [null as string], }); + get hasPassword(): boolean { + return this.originalSendView?.password != null; + } + constructor( protected sendFormContainer: SendFormContainer, protected formBuilder: FormBuilder, protected i18nService: I18nService, protected datePipe: DatePipe, protected environmentService: EnvironmentService, + private configService: ConfigService, + private accountService: AccountService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private generatorService: CredentialGeneratorService, + private sendApiService: SendApiService, + private dialogService: DialogService, + private toastService: ToastService, ) { - this.sendDetailsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { - this.sendFormContainer.patchSend((send) => { - return Object.assign(send, { - name: value.name, - deletionDate: new Date(this.formattedDeletionDate), - expirationDate: new Date(this.formattedDeletionDate), - } as SendView); + this.sendDetailsForm.valueChanges + .pipe( + tap((value) => { + if (Utils.isNullOrWhitespace(value.password)) { + value.password = null; + } + }), + takeUntilDestroyed(), + ) + .subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + return Object.assign(send, { + name: value.name, + deletionDate: new Date(this.formattedDeletionDate), + expirationDate: new Date(this.formattedDeletionDate), + password: value.password, + emails: value.emails + ? value.emails + .split(",") + .map((e) => e.trim()) + .filter((e) => e.length > 0) + : null, + } as unknown as SendView); + }); + }); + + this.sendDetailsForm + .get("authType") + .valueChanges.pipe(takeUntilDestroyed()) + .subscribe((type) => { + const emailsControl = this.sendDetailsForm.get("emails"); + const passwordControl = this.sendDetailsForm.get("password"); + + if (type === AuthType.Password) { + emailsControl.setValue(null); + emailsControl.clearValidators(); + passwordControl.setValidators([Validators.required]); + } else if (type === AuthType.Email) { + passwordControl.setValue(null); + passwordControl.clearValidators(); + emailsControl.setValidators([Validators.required, this.emailListValidator()]); + } else { + emailsControl.setValue(null); + emailsControl.clearValidators(); + passwordControl.setValue(null); + passwordControl.clearValidators(); + } + emailsControl.updateValueAndValidity(); + passwordControl.updateValueAndValidity(); }); - }); this.sendFormContainer.registerChildForm("sendDetailsForm", this.sendDetailsForm); } @@ -141,8 +244,15 @@ export class SendDetailsComponent implements OnInit { this.sendDetailsForm.patchValue({ name: this.originalSendView.name, selectedDeletionDatePreset: this.originalSendView.deletionDate.toString(), + password: this.hasPassword ? "************" : null, + authType: this.originalSendView.authType, + emails: this.originalSendView.emails?.join(", ") ?? null, }); + if (this.hasPassword) { + this.sendDetailsForm.get("password")?.disable(); + } + if (this.originalSendView.deletionDate) { this.customDeletionDateOption = { name: this.datePipe.transform(this.originalSendView.deletionDate, "short"), @@ -193,4 +303,61 @@ export class SendDetailsComponent implements OnInit { const milliseconds = now.setTime(now.getTime() + preset * 60 * 60 * 1000); return new Date(milliseconds).toString(); } + + emailListValidator(): ValidatorFn { + return (control: FormControl): ValidationErrors | null => { + if (!control.value) { + return null; + } + const emails = control.value.split(",").map((e: string) => e.trim()); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const invalidEmails = emails.filter((e: string) => e.length > 0 && !emailRegex.test(e)); + return invalidEmails.length > 0 ? { email: true } : null; + }; + } + + generatePassword = async () => { + const on$ = new BehaviorSubject({ source: "send", type: Type.password }); + const account$ = this.accountService.activeAccount$.pipe( + pin({ name: () => "send-details.component", distinct: (p, c) => p.id === c.id }), + ); + const generatedCredential = await firstValueFrom( + this.generatorService.generate$({ on$, account$ }), + ); + + this.sendDetailsForm.patchValue({ + password: generatedCredential.credential, + }); + }; + + removePassword = async () => { + if (!this.originalSendView?.password) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "removePassword" }, + content: { key: "removePasswordConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + this.passwordRemoved = true; + + await this.sendApiService.removePassword(this.originalSendView.id); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("removedPassword"), + }); + + this.originalSendView.password = null; + this.sendDetailsForm.patchValue({ + password: null, + }); + this.sendDetailsForm.get("password")?.enable(); + }; } From 23bd806d924de106ec2da776afe4a719f547c452 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 28 Jan 2026 10:10:39 -0500 Subject: [PATCH 05/13] Have AppSec own Checkmarx config (#18623) --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a768a9d51f6..b7fb098e662 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -221,6 +221,9 @@ apps/web/src/locales/en/messages.json **/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre **/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre +# Scanning tools +.checkmarx/ @bitwarden/team-appsec + ## Overrides # For the time being platform owns tsconfig and jest config # These overrides will be removed after Nx is implemented From 6d0f0b62f222a07a84836451b8741347017b1e0a Mon Sep 17 00:00:00 2001 From: Jared Date: Wed, 28 Jan 2026 10:24:06 -0500 Subject: [PATCH 06/13] [PM-31155] reorder policies in policies page (#18564) * Refactor policy edit registration to centralize ownership and improve organization. Reordered policies for clarity and added new policies for enhanced functionality. * Add PolicyOrderPipe for sorting policies and update policies component to utilize it * Add organizationDataOwnership to POLICY_ORDER_MAP for policy sorting * Fix PR comments --- .../organizations/policies/index.ts | 1 + .../policies/pipes/policy-order.pipe.ts | 66 +++++++++++++++++++ .../policies/policies.component.html | 2 +- .../policies/policies.component.ts | 3 +- 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts diff --git a/apps/web/src/app/admin-console/organizations/policies/index.ts b/apps/web/src/app/admin-console/organizations/policies/index.ts index eb614e180e1..8e730d3a6b8 100644 --- a/apps/web/src/app/admin-console/organizations/policies/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/index.ts @@ -5,3 +5,4 @@ export { POLICY_EDIT_REGISTER } from "./policy-register-token"; export { AutoConfirmPolicy } from "./policy-edit-definitions"; export { PolicyEditDialogResult } from "./policy-edit-dialog.component"; export * from "./policy-edit-dialogs"; +export { PolicyOrderPipe } from "./pipes/policy-order.pipe"; diff --git a/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts new file mode 100644 index 00000000000..ec9fef23b9d --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts @@ -0,0 +1,66 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +import { BasePolicyEditDefinition } from "../base-policy-edit.component"; + +/** + * Order mapping for policies. Policies are ordered according to this mapping. + * Policies not in this mapping will appear at the end, maintaining their relative order. + */ +const POLICY_ORDER_MAP = new Map([ + ["singleOrg", 1], + ["organizationDataOwnership", 2], + ["centralizeDataOwnership", 2], + ["masterPassPolicyTitle", 3], + ["accountRecoveryPolicy", 4], + ["requireSso", 5], + ["automaticAppLoginWithSSO", 6], + ["twoStepLoginPolicyTitle", 7], + ["blockClaimedDomainAccountCreation", 8], + ["sessionTimeoutPolicyTitle", 9], + ["removeUnlockWithPinPolicyTitle", 10], + ["passwordGenerator", 11], + ["uriMatchDetectionPolicy", 12], + ["activateAutofill", 13], + ["sendOptions", 14], + ["disableSend", 15], + ["restrictedItemTypePolicy", 16], + ["freeFamiliesSponsorship", 17], + ["disableExport", 18], +]); + +/** + * Default order for policies not in the mapping. This ensures unmapped policies + * appear at the end while maintaining their relative order. + */ +const DEFAULT_ORDER = 999; + +@Pipe({ + name: "policyOrder", + standalone: true, +}) +export class PolicyOrderPipe implements PipeTransform { + transform( + policies: readonly BasePolicyEditDefinition[] | null | undefined, + ): BasePolicyEditDefinition[] { + if (policies == null || policies.length === 0) { + return []; + } + + const sortedPolicies = [...policies]; + + sortedPolicies.sort((a, b) => { + const orderA = POLICY_ORDER_MAP.get(a.name) ?? DEFAULT_ORDER; + const orderB = POLICY_ORDER_MAP.get(b.name) ?? DEFAULT_ORDER; + + if (orderA !== orderB) { + return orderA - orderB; + } + + const indexA = policies.indexOf(a); + const indexB = policies.indexOf(b); + return indexA - indexB; + }); + + return sortedPolicies; + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.html b/apps/web/src/app/admin-console/organizations/policies/policies.component.html index c38092146ab..902c7e79d55 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.html @@ -15,7 +15,7 @@ } @else { - @for (p of policies$ | async; track $index) { + @for (p of policies$ | async | policyOrder; track $index) { @if (p.display$(organization, configService) | async) { diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 1f9a8deaa85..d13a2097628 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -21,13 +21,14 @@ import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component"; +import { PolicyOrderPipe } from "./pipes/policy-order.pipe"; import { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; import { PolicyListService } from "./policy-list.service"; import { POLICY_EDIT_REGISTER } from "./policy-register-token"; @Component({ templateUrl: "policies.component.html", - imports: [SharedModule, HeaderModule], + imports: [SharedModule, HeaderModule, PolicyOrderPipe], providers: [ safeProvider({ provide: PolicyListService, From 61225e6015acba5fdf6b357c8aa7cc5bad925dc7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:36:16 -0500 Subject: [PATCH 07/13] [deps]: Update actions/setup-node action to v6 (#17038) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-browser.yml | 6 +++--- .github/workflows/build-cli.yml | 4 ++-- .github/workflows/build-desktop.yml | 14 +++++++------- .github/workflows/chromatic.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/nx.yml | 2 +- .github/workflows/publish-cli.yml | 2 +- .github/workflows/sdk-breaking-change-check.yml | 2 +- .github/workflows/test.yml | 2 +- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 7b35baf01e2..ef2c91f0a7d 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -152,7 +152,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -260,7 +260,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -392,7 +392,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index d0abe8e12e7..75820c54977 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -130,7 +130,7 @@ jobs: } >> "$GITHUB_ENV" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -326,7 +326,7 @@ jobs: choco install nasm --no-progress - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 0d4009e54f9..c021dedd8e1 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -183,7 +183,7 @@ jobs: uses: bitwarden/gh-actions/free-disk-space@main - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -339,7 +339,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -487,7 +487,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -755,7 +755,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1000,7 +1000,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1240,7 +1240,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1515,7 +1515,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index c7d80b82baa..6189744fe67 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -58,7 +58,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6a5f6774474..7862c14c186 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -64,7 +64,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 3a7431c07f0..e468ead4f1e 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index ef287b0de08..5f6ee83e41f 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -216,7 +216,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} registry-url: "https://registry.npmjs.org/" diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml index 765e900af5c..eab0dffeda4 100644 --- a/.github/workflows/sdk-breaking-change-check.yml +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -76,7 +76,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eedf991d826..41b75c5a31d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' From 5dc49f21d2e5aa145fe9611222b4c0c5659db1c5 Mon Sep 17 00:00:00 2001 From: Will Martin Date: Wed, 28 Jan 2026 11:36:27 -0500 Subject: [PATCH 08/13] [CL-82] rename `bit-icon` to `bit-svg`; create new `bit-icon` component for font icons (#18584) * rename bit-icon to bit-svg; create new bit-icon for font icons Co-Authored-By: Claude Sonnet 4.5 * find and replace current usage Co-Authored-By: Claude Sonnet 4.5 * add custom eslint warning Co-Authored-By: Claude Sonnet 4.5 * fix incorrect usage * fix tests * fix tests * Update libs/components/src/svg/index.ts Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * Update libs/eslint/components/no-bwi-class-usage.spec.mjs Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * update component api * update class name * use icon type in iconButton component * update type Icon --> BitSvg * fix bad renames * fix more renames * fix bad input * revert iconButton type * fix lint * fix more inputs * misc fixes Co-Authored-By: Claude Sonnet 4.5 * fix test * add eslint ignore * fix lint * add comparison story --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../popup/phishing-warning.component.ts | 4 +- .../popup-tab-navigation.component.html | 6 +- .../layout/popup-tab-navigation.component.ts | 10 +- ...tension-anon-layout-wrapper.component.html | 2 +- ...extension-anon-layout-wrapper.component.ts | 8 +- .../send-created/send-created.component.html | 2 +- .../send-created.component.spec.ts | 4 +- .../send-created/send-created.component.ts | 4 +- .../intro-carousel.component.html | 8 +- .../intro-carousel.component.ts | 4 +- .../credentials/fido2-create.component.html | 4 +- .../credentials/fido2-create.component.ts | 4 +- .../fido2-excluded-ciphers.component.html | 4 +- .../fido2-excluded-ciphers.component.ts | 4 +- .../credentials/fido2-vault.component.html | 2 +- .../credentials/fido2-vault.component.ts | 4 +- .../layouts/organization-layout.component.ts | 4 +- .../auto-confirm-policy.component.html | 2 +- .../accept-family-sponsorship.component.html | 4 +- .../accept-family-sponsorship.component.ts | 4 +- ...wo-factor-setup-authenticator.component.ts | 4 +- .../two-factor-setup-duo.component.ts | 4 +- .../two-factor-setup-email.component.ts | 4 +- .../create-credential-dialog.component.html | 4 +- ...nization-subscription-cloud.component.html | 2 +- .../subscription-hidden.component.ts | 2 +- .../shared/sm-subscribe.component.html | 2 +- .../reports/shared/models/report-entry.ts | 4 +- .../report-card/report-card.component.html | 2 +- .../report-card/report-card.component.ts | 4 +- .../shared/report-card/report-card.stories.ts | 4 +- .../shared/report-list/report-list.stories.ts | 4 +- .../app/layouts/header/web-header.stories.ts | 4 +- .../src/app/layouts/user-layout.component.ts | 4 +- .../onboarding/onboarding.stories.ts | 4 +- apps/web/src/app/shared/shared.module.ts | 6 +- .../send-success-drawer-dialog.component.html | 2 +- .../browser-extension-prompt.component.ts | 4 +- .../manually-open-extension.component.html | 6 +- .../manually-open-extension.component.ts | 4 +- .../setup-extension.component.html | 2 +- .../setup-extension.component.ts | 4 +- .../vault/individual-vault/vault.component.ts | 4 +- .../manage/accept-provider.component.html | 6 +- .../providers/providers-layout.component.ts | 8 +- .../setup/setup-provider.component.html | 6 +- .../setup/setup-business-unit.component.html | 6 +- .../empty-state-card.component.html | 12 +- .../empty-state-card.component.ts | 8 +- .../shared/org-suspended.component.ts | 4 +- eslint.config.mjs | 1 + .../components/two-factor-icon.component.html | 2 +- .../components/two-factor-icon.component.ts | 4 +- .../login-via-webauthn.component.ts | 4 +- libs/angular/src/jslib.module.ts | 6 +- libs/assets/README.md | 4 +- libs/assets/src/svg/icon-service.ts | 25 ---- libs/assets/src/svg/index.ts | 2 +- .../svg/{icon-service.spec.ts => svg.spec.ts} | 12 +- libs/assets/src/svg/svg.ts | 25 ++++ .../src/svg/svgs/account-warning.icon.ts | 4 +- libs/assets/src/svg/svgs/active-send.icon.ts | 4 +- libs/assets/src/svg/svgs/admin-console.ts | 4 +- libs/assets/src/svg/svgs/auto-confirmation.ts | 4 +- .../svg/svgs/background-left-illustration.ts | 4 +- .../svg/svgs/background-right-illustration.ts | 4 +- libs/assets/src/svg/svgs/bitwarden-icon.ts | 4 +- .../src/svg/svgs/bitwarden-logo.icon.ts | 4 +- libs/assets/src/svg/svgs/browser-extension.ts | 4 +- .../src/svg/svgs/business-unit-portal.ts | 4 +- .../src/svg/svgs/business-welcome.icon.ts | 4 +- libs/assets/src/svg/svgs/carousel-icon.ts | 4 +- libs/assets/src/svg/svgs/credit-card.icon.ts | 4 +- libs/assets/src/svg/svgs/deactivated-org.ts | 4 +- libs/assets/src/svg/svgs/devices.icon.ts | 4 +- libs/assets/src/svg/svgs/domain.icon.ts | 4 +- libs/assets/src/svg/svgs/empty-trash.ts | 4 +- libs/assets/src/svg/svgs/favorites.icon.ts | 4 +- libs/assets/src/svg/svgs/gear.ts | 4 +- libs/assets/src/svg/svgs/generator.ts | 6 +- libs/assets/src/svg/svgs/item-types.ts | 4 +- libs/assets/src/svg/svgs/lock.icon.ts | 4 +- libs/assets/src/svg/svgs/login-cards.ts | 4 +- .../src/svg/svgs/no-credentials.icon.ts | 4 +- libs/assets/src/svg/svgs/no-folders.ts | 4 +- libs/assets/src/svg/svgs/no-results.ts | 4 +- libs/assets/src/svg/svgs/no-send.icon.ts | 4 +- libs/assets/src/svg/svgs/party.ts | 4 +- libs/assets/src/svg/svgs/password-manager.ts | 4 +- libs/assets/src/svg/svgs/provider-portal.ts | 4 +- .../svg/svgs/registration-check-email.icon.ts | 4 +- .../svg/svgs/registration-user-add.icon.ts | 4 +- .../assets/src/svg/svgs/report-breach.icon.ts | 4 +- .../svg/svgs/report-exposed-passwords.icon.ts | 4 +- .../svgs/report-unsecured-websites.icon.ts | 4 +- libs/assets/src/svg/svgs/restricted-view.ts | 4 +- .../src/svg/svgs/secrets-manager-alt.ts | 4 +- libs/assets/src/svg/svgs/secrets-manager.ts | 4 +- libs/assets/src/svg/svgs/security.ts | 4 +- libs/assets/src/svg/svgs/send.ts | 6 +- libs/assets/src/svg/svgs/settings.ts | 6 +- libs/assets/src/svg/svgs/shield.ts | 4 +- libs/assets/src/svg/svgs/sso-key.icon.ts | 4 +- .../two-factor-auth-authenticator.icon.ts | 4 +- .../src/svg/svgs/two-factor-auth-duo.icon.ts | 8 +- .../svg/svgs/two-factor-auth-email.icon.ts | 4 +- ...wo-factor-auth-security-key-failed.icon.ts | 4 +- .../svgs/two-factor-auth-security-key.icon.ts | 4 +- .../svg/svgs/two-factor-auth-webauthn.icon.ts | 4 +- .../svg/svgs/two-factor-auth-yubico.icon.ts | 7 +- .../src/svg/svgs/two-factor-timeout.icon.ts | 4 +- libs/assets/src/svg/svgs/unlocked.icon.ts | 4 +- libs/assets/src/svg/svgs/user-lock.icon.ts | 4 +- ...erification-biometrics-fingerprint.icon.ts | 4 +- libs/assets/src/svg/svgs/vault-open.ts | 4 +- libs/assets/src/svg/svgs/vault.icon.ts | 4 +- libs/assets/src/svg/svgs/vault.ts | 6 +- libs/assets/src/svg/svgs/wave.icon.ts | 4 +- .../registration-link-expired.component.ts | 4 +- .../registration-start.component.ts | 4 +- .../two-factor-options.component.html | 36 +++--- .../two-factor-options.component.ts | 4 +- ...ser-verification-form-input.component.html | 2 +- .../user-verification-form-input.component.ts | 4 +- .../anon-layout-wrapper.component.ts | 6 +- .../src/anon-layout/anon-layout.component.ts | 8 +- .../src/anon-layout/anon-layout.stories.ts | 4 +- .../components/src/callout/callout.stories.ts | 4 +- libs/components/src/header/header.stories.ts | 4 +- libs/components/src/icon/icon.component.ts | 39 +++--- libs/components/src/icon/icon.mdx | 121 ++++-------------- libs/components/src/icon/icon.module.ts | 6 +- libs/components/src/icon/icon.stories.ts | 79 +++++++----- libs/components/src/icon/index.ts | 1 + libs/components/src/index.ts | 1 + .../landing-header.component.html | 2 +- .../landing-header.component.ts | 4 +- .../landing-hero.component.html | 2 +- .../landing-layout/landing-hero.component.ts | 8 +- .../landing-layout.component.html | 4 +- .../landing-layout.component.ts | 4 +- .../src/navigation/nav-logo.component.html | 2 +- .../src/navigation/nav-logo.component.ts | 8 +- .../src/no-items/no-items.component.html | 2 +- .../src/no-items/no-items.component.ts | 9 +- .../kitchen-sink-shared.module.ts | 6 +- libs/components/src/svg/index.ts | 2 + libs/components/src/svg/svg.component.ts | 31 +++++ .../svg.components.spec.ts} | 20 +-- libs/components/src/svg/svg.mdx | 120 +++++++++++++++++ libs/components/src/svg/svg.module.ts | 9 ++ libs/components/src/svg/svg.stories.ts | 50 ++++++++ libs/eslint/components/index.mjs | 2 + libs/eslint/components/no-bwi-class-usage.mjs | 45 +++++++ .../components/no-bwi-class-usage.spec.mjs | 44 +++++++ .../require-theme-colors-in-svg.mjs | 4 +- .../require-theme-colors-in-svg.spec.mjs | 14 +- .../pricing-card.component.spec.ts | 10 +- .../pricing-card/pricing-card.component.ts | 4 +- .../carousel-button.component.html | 2 +- .../carousel-button.component.ts | 4 +- 161 files changed, 764 insertions(+), 529 deletions(-) delete mode 100644 libs/assets/src/svg/icon-service.ts rename libs/assets/src/svg/{icon-service.spec.ts => svg.spec.ts} (69%) create mode 100644 libs/assets/src/svg/svg.ts create mode 100644 libs/components/src/svg/index.ts create mode 100644 libs/components/src/svg/svg.component.ts rename libs/components/src/{icon/icon.components.spec.ts => svg/svg.components.spec.ts} (55%) create mode 100644 libs/components/src/svg/svg.mdx create mode 100644 libs/components/src/svg/svg.module.ts create mode 100644 libs/components/src/svg/svg.stories.ts create mode 100644 libs/eslint/components/no-bwi-class-usage.mjs create mode 100644 libs/eslint/components/no-bwi-class-usage.spec.mjs diff --git a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts index d8e9895237c..419de04d9f4 100644 --- a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts @@ -10,7 +10,7 @@ import { ButtonModule, CheckboxModule, FormFieldModule, - IconModule, + SvgModule, IconTileComponent, LinkModule, CalloutComponent, @@ -31,7 +31,7 @@ import { templateUrl: "phishing-warning.component.html", imports: [ CommonModule, - IconModule, + SvgModule, JslibModule, LinkModule, FormFieldModule, diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index bce2b5033ae..e04d302ea2c 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -18,11 +18,11 @@ type="button" role="link" > - + > {{ button.label | i18n }} diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index 26138d57954..5a40b72daff 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -3,15 +3,15 @@ import { Component, Input } from "@angular/core"; import { RouterModule } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { IconModule, LinkModule } from "@bitwarden/components"; +import { SvgModule, LinkModule } from "@bitwarden/components"; export type NavButton = { label: string; page: string; - icon: Icon; - iconActive: Icon; + icon: BitSvg; + iconActive: BitSvg; showBerry?: boolean; }; @@ -20,7 +20,7 @@ export type NavButton = { @Component({ selector: "popup-tab-navigation", templateUrl: "popup-tab-navigation.component.html", - imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule], + imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule], host: { class: "tw-block tw-size-full tw-flex tw-flex-col", }, diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index 484f9680519..2cf1998bb05 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -6,7 +6,7 @@ [pageTitle]="''" >
- +
diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index 3a50f03e982..e07e9c50554 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -5,10 +5,10 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, switchMap, takeUntil, tap } from "rxjs"; -import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; +import { BitwardenLogo, BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { - IconModule, + SvgModule, Translation, AnonLayoutComponent, AnonLayoutWrapperData, @@ -38,7 +38,7 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData { CommonModule, CurrentAccountComponent, I18nPipe, - IconModule, + SvgModule, PopOutComponent, PopupPageComponent, PopupHeaderComponent, @@ -54,7 +54,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { protected pageTitle: string; protected pageSubtitle: string; - protected pageIcon: Icon; + protected pageIcon: BitSvg; protected showReadonlyHostname: boolean; protected maxWidth: "md" | "3xl"; protected hasLoggedInAccount: boolean = false; diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html index 828c1667c57..94c1df46eea 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -14,7 +14,7 @@ class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-center tw-items-center tw-gap-2 tw-h-full tw-px-5" >
- +

{{ "createdSendSuccessfully" | i18n }} diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index 521d72bba0c..a19897b6bbc 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -14,7 +14,7 @@ import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/defau import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; -import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components"; +import { ButtonModule, I18nMockService, SvgModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; @@ -76,7 +76,7 @@ describe("SendCreatedComponent", () => { RouterTestingModule, JslibModule, ButtonModule, - IconModule, + SvgModule, PopOutComponent, PopupHeaderComponent, PopupPageComponent, diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index e9109ec6c21..e3717075e24 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -13,7 +13,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; -import { ButtonModule, IconModule, ToastService } from "@bitwarden/components"; +import { ButtonModule, SvgModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; @@ -34,7 +34,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page PopupPageComponent, RouterModule, PopupFooterComponent, - IconModule, + SvgModule, ], }) export class SendCreatedComponent { diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html index 5f19092d6b0..1980e8aa356 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html @@ -2,7 +2,7 @@
- +

{{ "securityPrioritized" | i18n }}

{{ "securityPrioritizedBody" | i18n }}

@@ -11,7 +11,7 @@
- +

{{ "quickLogin" | i18n }}

{{ "quickLoginBody" | i18n }}

@@ -20,7 +20,7 @@
- +

{{ "secureUser" | i18n }}

{{ "secureUserBody" | i18n }}

@@ -29,7 +29,7 @@
- +

{{ "secureDevices" | i18n }}

{{ "secureDevicesBody" | i18n }}

diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts index 48c8f5682bc..5ad44c2f545 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts @@ -3,7 +3,7 @@ import { Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ItemTypes, LoginCards, NoCredentialsIcon, DevicesIcon } from "@bitwarden/assets/svg"; -import { ButtonModule, DialogModule, IconModule, TypographyModule } from "@bitwarden/components"; +import { ButtonModule, DialogModule, SvgModule, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselModule } from "@bitwarden/vault"; @@ -17,7 +17,7 @@ import { IntroCarouselService } from "../../../services/intro-carousel.service"; imports: [ VaultCarouselModule, ButtonModule, - IconModule, + SvgModule, DialogModule, TypographyModule, JslibModule, diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html index 67fc76aa317..4d3748d4303 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html @@ -5,7 +5,7 @@ >
- +

{{ "savePasskeyQuestion" | i18n }} @@ -28,7 +28,7 @@
- +
{{ "noMatchingLoginsForSite" | i18n }}
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts index 67237bedccd..d18fb6752e3 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts @@ -16,7 +16,7 @@ import { BadgeModule, ButtonModule, DialogModule, - IconModule, + SvgModule, ItemModule, SectionComponent, TableModule, @@ -42,7 +42,7 @@ import { BitIconButtonComponent, TableModule, JslibModule, - IconModule, + SvgModule, ButtonModule, DialogModule, SectionComponent, diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html index 792934deedc..817c79eba3a 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html +++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html @@ -5,7 +5,7 @@ >
- +

{{ "savePasskeyQuestion" | i18n }} @@ -30,7 +30,7 @@ class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-start tw-items-center tw-gap-2 tw-h-full tw-px-5" >
- +
{{ "passkeyAlreadyExists" | i18n }} {{ "applicationDoesNotSupportDuplicates" | i18n }} diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts index 049771c2252..274956be0eb 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts @@ -9,7 +9,7 @@ import { BadgeModule, ButtonModule, DialogModule, - IconModule, + SvgModule, ItemModule, SectionComponent, TableModule, @@ -32,7 +32,7 @@ import { BitIconButtonComponent, TableModule, JslibModule, - IconModule, + SvgModule, ButtonModule, DialogModule, SectionComponent, diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html index ed04993d09f..df9458d8b14 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html @@ -5,7 +5,7 @@ >
- +

{{ "passkeyLogin" | i18n }}

diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts index 897e825c53e..635ba3972cb 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts @@ -24,7 +24,7 @@ import { ButtonModule, DialogModule, DialogService, - IconModule, + SvgModule, ItemModule, SectionComponent, TableModule, @@ -48,7 +48,7 @@ import { BitIconButtonComponent, TableModule, JslibModule, - IconModule, + SvgModule, ButtonModule, DialogModule, SectionComponent, diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index b00e4d9840d..2d1fde10856 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -26,7 +26,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; -import { BannerModule, IconModule } from "@bitwarden/components"; +import { BannerModule, SvgModule } from "@bitwarden/components"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; @@ -47,7 +47,7 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module"; RouterModule, JslibModule, WebLayoutModule, - IconModule, + SvgModule, OrgSwitcherComponent, BannerModule, TaxIdWarningComponent, diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html index 54f166b662e..a8e3236dad8 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html @@ -44,7 +44,7 @@
- +
  1. 1. {{ "autoConfirmExtension1" | i18n }}
  2. diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html index ca1264829b9..0255e1a6a99 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html +++ b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html @@ -1,7 +1,7 @@
    - - + +
    - +

    {{ "creatingPasskeyLoading" | i18n }}

    {{ "creatingPasskeyLoadingInfo" | i18n }}

    @@ -27,7 +27,7 @@ class="tw-flex tw-flex-col tw-items-center" >
    - +

    {{ "errorCreatingPasskey" | i18n }}

    {{ "errorCreatingPasskeyInfo" | i18n }}

    diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 4858deabec6..496ddb4ff9b 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -242,7 +242,7 @@
    - +

    {{ "billingManagedByProvider" | i18n: userOrg.providerName }}

    {{ "billingContactProviderForAssistance" | i18n }}

    diff --git a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts index ef6e2dd0495..249cf999305 100644 --- a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts +++ b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts @@ -10,7 +10,7 @@ import { GearIcon } from "@bitwarden/assets/svg"; selector: "app-org-subscription-hidden", template: `
    - +

    {{ "billingManagedByProvider" | i18n: providerName }}

    {{ "billingContactProviderForAssistance" | i18n }}

    diff --git a/apps/web/src/app/billing/shared/sm-subscribe.component.html b/apps/web/src/app/billing/shared/sm-subscribe.component.html index 6cdaeb9476d..70990d2ee4c 100644 --- a/apps/web/src/app/billing/shared/sm-subscribe.component.html +++ b/apps/web/src/app/billing/shared/sm-subscribe.component.html @@ -2,7 +2,7 @@

    {{ "moreFromBitwarden" | i18n }}

    - +
    - +
    diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts index 87c005ea46b..2f4934381b9 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Component, Input } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { ReportVariant } from "../models/report-variant"; @@ -25,7 +25,7 @@ export class ReportCardComponent { @Input() route: string; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() icon: Icon; + @Input() icon: BitSvg; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() variant: ReportVariant; diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts index 93ea79c8418..4f442dc9380 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts @@ -14,7 +14,7 @@ import { BaseCardComponent, CardContentComponent, I18nMockService, - IconModule, + SvgModule, } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; @@ -31,7 +31,7 @@ export default { JslibModule, BadgeModule, CardContentComponent, - IconModule, + SvgModule, RouterTestingModule, PremiumBadgeComponent, BaseCardComponent, diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts index 5a95e332816..9686644bd74 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts @@ -12,7 +12,7 @@ import { BadgeModule, BaseCardComponent, CardContentComponent, - IconModule, + SvgModule, } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; @@ -31,7 +31,7 @@ export default { JslibModule, BadgeModule, RouterTestingModule, - IconModule, + SvgModule, PremiumBadgeComponent, CardContentComponent, BaseCardComponent, diff --git a/apps/web/src/app/layouts/header/web-header.stories.ts b/apps/web/src/app/layouts/header/web-header.stories.ts index 88c98f01e6c..3b3b28b8e45 100644 --- a/apps/web/src/app/layouts/header/web-header.stories.ts +++ b/apps/web/src/app/layouts/header/web-header.stories.ts @@ -24,7 +24,7 @@ import { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, @@ -94,7 +94,7 @@ export default { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, TabsModule, diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 90207f59ad4..33bce661c65 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -16,7 +16,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { BillingFreeFamiliesNavItemComponent } from "../billing/shared/billing-free-families-nav-item.component"; @@ -32,7 +32,7 @@ import { WebLayoutModule } from "./web-layout.module"; RouterModule, JslibModule, WebLayoutModule, - IconModule, + SvgModule, BillingFreeFamiliesNavItemComponent, ], }) diff --git a/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts b/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts index 6d051a91f7e..6873700e2bc 100644 --- a/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts +++ b/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts @@ -4,7 +4,7 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an import { delay, of, startWith } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { LinkModule, IconModule, ProgressModule } from "@bitwarden/components"; +import { LinkModule, SvgModule, ProgressModule } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../core/tests"; @@ -16,7 +16,7 @@ export default { component: OnboardingComponent, decorators: [ moduleMetadata({ - imports: [JslibModule, RouterModule, LinkModule, IconModule, ProgressModule], + imports: [JslibModule, RouterModule, LinkModule, SvgModule, ProgressModule], declarations: [OnboardingTaskComponent], }), applicationConfig({ diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index 6012e4867e1..b83555fd84e 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -18,7 +18,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, @@ -63,7 +63,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, @@ -99,7 +99,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html index a484f210f62..90210df4658 100644 --- a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html @@ -8,7 +8,7 @@ >
    - +
    diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts index 54d62b8414a..51603724c57 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts @@ -12,7 +12,7 @@ import { ActivatedRoute } from "@angular/router"; import { map, Observable, of, tap } from "rxjs"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; -import { ButtonComponent, IconModule } from "@bitwarden/components"; +import { ButtonComponent, SvgModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { @@ -24,7 +24,7 @@ import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manua @Component({ selector: "vault-browser-extension-prompt", templateUrl: "./browser-extension-prompt.component.html", - imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent], + imports: [CommonModule, I18nPipe, ButtonComponent, SvgModule, ManuallyOpenExtensionComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class BrowserExtensionPromptComponent implements OnInit, OnDestroy { diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html index d15cdaa712b..7da964f5fdb 100644 --- a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html @@ -1,8 +1,8 @@

    {{ "openExtensionFromToolbarPart1" | i18n }} - + > {{ "openExtensionFromToolbarPart2" | i18n }}

    diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts index 435e847f6e9..e4db0a55097 100644 --- a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts @@ -1,14 +1,14 @@ import { Component, ChangeDetectionStrategy } from "@angular/core"; import { BitwardenIcon } from "@bitwarden/assets/svg"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: "vault-manually-open-extension", templateUrl: "./manually-open-extension.component.html", - imports: [I18nPipe, IconModule], + imports: [I18nPipe, SvgModule], }) export class ManuallyOpenExtensionComponent { protected BitwardenIcon = BitwardenIcon; diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html index 8cfd394b854..d8cd562ac61 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -31,7 +31,7 @@
    - +

    {{ diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts index cfc1961c4d8..1b2c0144549 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts @@ -18,7 +18,7 @@ import { CenterPositionStrategy, DialogRef, DialogService, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -52,7 +52,7 @@ type SetupExtensionState = UnionOfValues; JslibModule, ButtonComponent, LinkModule, - IconModule, + SvgModule, RouterModule, AddExtensionVideosComponent, ManuallyOpenExtensionComponent, diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 532757852a3..b07de88baf9 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -33,7 +33,7 @@ import { EmptyTrash, FavoritesIcon, ItemTypes, - Icon, + BitSvg, } from "@bitwarden/assets/svg"; import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -160,7 +160,7 @@ type EmptyStateType = "trash" | "favorites" | "archive"; type EmptyStateItem = { title: string; description: string; - icon: Icon; + icon: BitSvg; }; type EmptyStateMap = Record; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html index bc209ead2bd..1a1dd5b1bbb 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html @@ -1,10 +1,10 @@
    - + >

    (); protected provider$: Observable; - protected logo$: Observable; + protected logo$: Observable; protected canAccessBilling$: Observable; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html index cb8eaea80c3..ff148098cc6 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html @@ -1,10 +1,10 @@

    - + >

    - + >

    - + >

    }
    @@ -94,11 +94,11 @@
    - + >
    }
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts index 54d97e984ec..c28de5e9952 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts @@ -1,17 +1,17 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, isDevMode, OnInit } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; -import { ButtonModule, IconModule } from "@bitwarden/components"; +import { BitSvg } from "@bitwarden/assets/svg"; +import { ButtonModule, SvgModule } from "@bitwarden/components"; @Component({ selector: "empty-state-card", templateUrl: "./empty-state-card.component.html", - imports: [CommonModule, IconModule, ButtonModule], + imports: [CommonModule, SvgModule, ButtonModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class EmptyStateCardComponent implements OnInit { - readonly icon = input(null); + readonly icon = input(null); readonly videoSrc = input(null); readonly title = input(""); readonly description = input(""); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts index f2e0d48fe1d..241f02fce7e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts @@ -2,7 +2,7 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { map, concatMap, firstValueFrom } from "rxjs"; -import { Icon, DeactivatedOrg } from "@bitwarden/assets/svg"; +import { BitSvg, DeactivatedOrg } from "@bitwarden/assets/svg"; import { getOrganizationById, OrganizationService, @@ -23,7 +23,7 @@ export class OrgSuspendedComponent { private route: ActivatedRoute, ) {} - protected DeactivatedOrg: Icon = DeactivatedOrg; + protected DeactivatedOrg: BitSvg = DeactivatedOrg; protected organizationName$ = this.route.params.pipe( concatMap(async (params) => { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); diff --git a/eslint.config.mjs b/eslint.config.mjs index e8f43d4a9ea..974aaafeef6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -207,6 +207,7 @@ export default tseslint.config( "error", { ignoreIfHas: ["bitPasswordInputToggle"] }, ], + "@bitwarden/components/no-bwi-class-usage": "warn", }, }, diff --git a/libs/angular/src/auth/components/two-factor-icon.component.html b/libs/angular/src/auth/components/two-factor-icon.component.html index 14558700757..555176225af 100644 --- a/libs/angular/src/auth/components/two-factor-icon.component.html +++ b/libs/angular/src/auth/components/two-factor-icon.component.html @@ -1,6 +1,6 @@
    - +
    0) { - throw new DynamicContentNotAllowedError(); - } - - return new Icon(strings[0]); -} diff --git a/libs/assets/src/svg/index.ts b/libs/assets/src/svg/index.ts index 9f86a14f772..6a0fff490ff 100644 --- a/libs/assets/src/svg/index.ts +++ b/libs/assets/src/svg/index.ts @@ -1,2 +1,2 @@ export * from "./svgs"; -export * from "./icon-service"; +export * from "./svg"; diff --git a/libs/assets/src/svg/icon-service.spec.ts b/libs/assets/src/svg/svg.spec.ts similarity index 69% rename from libs/assets/src/svg/icon-service.spec.ts rename to libs/assets/src/svg/svg.spec.ts index 2561c85aefa..2d8401f0b5d 100644 --- a/libs/assets/src/svg/icon-service.spec.ts +++ b/libs/assets/src/svg/svg.spec.ts @@ -1,5 +1,5 @@ -import * as IconExports from "./icon-service"; -import { DynamicContentNotAllowedError, isIcon, svgIcon } from "./icon-service"; +import * as IconExports from "./svg"; +import { DynamicContentNotAllowedError, isBitSvg, svg } from "./svg"; describe("Icon", () => { it("exports should not expose Icon class", () => { @@ -8,13 +8,13 @@ describe("Icon", () => { describe("isIcon", () => { it("should return true when input is icon", () => { - const result = isIcon(svgIcon`icon`); + const result = isBitSvg(svg`icon`); expect(result).toBe(true); }); it("should return false when input is not an icon", () => { - const result = isIcon({ svg: "not an icon" }); + const result = isBitSvg({ svg: "not an icon" }); expect(result).toBe(false); }); @@ -24,13 +24,13 @@ describe("Icon", () => { it("should throw when attempting to create dynamic icons", () => { const dynamic = "some user input"; - const f = () => svgIcon`static and ${dynamic}`; + const f = () => svg`static and ${dynamic}`; expect(f).toThrow(DynamicContentNotAllowedError); }); it("should return svg content when supplying icon with svg string", () => { - const icon = svgIcon`safe static content`; + const icon = svg`safe static content`; expect(icon.svg).toBe("safe static content"); }); diff --git a/libs/assets/src/svg/svg.ts b/libs/assets/src/svg/svg.ts new file mode 100644 index 00000000000..71324ea4bac --- /dev/null +++ b/libs/assets/src/svg/svg.ts @@ -0,0 +1,25 @@ +class BitSvg { + constructor(readonly svg: string) {} +} + +// We only export the type to prohibit the creation of Svgs without using +// the `svg` template literal tag. +export type { BitSvg }; + +export function isBitSvg(svgContent: unknown): svgContent is BitSvg { + return svgContent instanceof BitSvg; +} + +export class DynamicContentNotAllowedError extends Error { + constructor() { + super("Dynamic content in icons is not allowed due to risk of user-injected XSS."); + } +} + +export function svg(strings: TemplateStringsArray, ...values: unknown[]): BitSvg { + if (values.length > 0) { + throw new DynamicContentNotAllowedError(); + } + + return new BitSvg(strings[0]); +} diff --git a/libs/assets/src/svg/svgs/account-warning.icon.ts b/libs/assets/src/svg/svgs/account-warning.icon.ts index 80e29dad870..81bf62d6e64 100644 --- a/libs/assets/src/svg/svgs/account-warning.icon.ts +++ b/libs/assets/src/svg/svgs/account-warning.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const AccountWarning = svgIcon` +export const AccountWarning = svg` diff --git a/libs/assets/src/svg/svgs/active-send.icon.ts b/libs/assets/src/svg/svgs/active-send.icon.ts index 3b12ee865d1..3016466e062 100644 --- a/libs/assets/src/svg/svgs/active-send.icon.ts +++ b/libs/assets/src/svg/svgs/active-send.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ActiveSendIcon = svgIcon` +export const ActiveSendIcon = svg` diff --git a/libs/assets/src/svg/svgs/admin-console.ts b/libs/assets/src/svg/svgs/admin-console.ts index 3e8f47ec4a5..146c834b442 100644 --- a/libs/assets/src/svg/svgs/admin-console.ts +++ b/libs/assets/src/svg/svgs/admin-console.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const AdminConsoleLogo = svgIcon` +const AdminConsoleLogo = svg` diff --git a/libs/assets/src/svg/svgs/auto-confirmation.ts b/libs/assets/src/svg/svgs/auto-confirmation.ts index 2a1416a5d25..5d0e0dd380c 100644 --- a/libs/assets/src/svg/svgs/auto-confirmation.ts +++ b/libs/assets/src/svg/svgs/auto-confirmation.ts @@ -1,5 +1,5 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const AutoConfirmSvg = svgIcon` +export const AutoConfirmSvg = svg` `; diff --git a/libs/assets/src/svg/svgs/background-left-illustration.ts b/libs/assets/src/svg/svgs/background-left-illustration.ts index a34f31f1621..f091f905c64 100644 --- a/libs/assets/src/svg/svgs/background-left-illustration.ts +++ b/libs/assets/src/svg/svgs/background-left-illustration.ts @@ -1,5 +1,5 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BackgroundLeftIllustration = svgIcon` +export const BackgroundLeftIllustration = svg` `; diff --git a/libs/assets/src/svg/svgs/background-right-illustration.ts b/libs/assets/src/svg/svgs/background-right-illustration.ts index 1c488f7242d..8f3bbba3462 100644 --- a/libs/assets/src/svg/svgs/background-right-illustration.ts +++ b/libs/assets/src/svg/svgs/background-right-illustration.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BackgroundRightIllustration = svgIcon` +export const BackgroundRightIllustration = svg` diff --git a/libs/assets/src/svg/svgs/bitwarden-icon.ts b/libs/assets/src/svg/svgs/bitwarden-icon.ts index 203460952b5..43aea78ced6 100644 --- a/libs/assets/src/svg/svgs/bitwarden-icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BitwardenIcon = svgIcon` +export const BitwardenIcon = svg` diff --git a/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts b/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts index 9c1c7248ec6..85d0a471a6e 100644 --- a/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BitwardenLogo = svgIcon` +export const BitwardenLogo = svg` Bitwarden diff --git a/libs/assets/src/svg/svgs/browser-extension.ts b/libs/assets/src/svg/svgs/browser-extension.ts index c15a536c007..2c40c584255 100644 --- a/libs/assets/src/svg/svgs/browser-extension.ts +++ b/libs/assets/src/svg/svgs/browser-extension.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BrowserExtensionIcon = svgIcon` +export const BrowserExtensionIcon = svg` diff --git a/libs/assets/src/svg/svgs/business-unit-portal.ts b/libs/assets/src/svg/svgs/business-unit-portal.ts index db3a6b8ef4f..cd06afcbf9a 100644 --- a/libs/assets/src/svg/svgs/business-unit-portal.ts +++ b/libs/assets/src/svg/svgs/business-unit-portal.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const BusinessUnitPortalLogo = svgIcon` +const BusinessUnitPortalLogo = svg` diff --git a/libs/assets/src/svg/svgs/business-welcome.icon.ts b/libs/assets/src/svg/svgs/business-welcome.icon.ts index 06c4950ec18..1d1caed8d47 100644 --- a/libs/assets/src/svg/svgs/business-welcome.icon.ts +++ b/libs/assets/src/svg/svgs/business-welcome.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BusinessWelcome = svgIcon` +export const BusinessWelcome = svg` diff --git a/libs/assets/src/svg/svgs/carousel-icon.ts b/libs/assets/src/svg/svgs/carousel-icon.ts index e29fd952098..4d645ad8029 100644 --- a/libs/assets/src/svg/svgs/carousel-icon.ts +++ b/libs/assets/src/svg/svgs/carousel-icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const CarouselIcon = svgIcon` +export const CarouselIcon = svg` diff --git a/libs/assets/src/svg/svgs/credit-card.icon.ts b/libs/assets/src/svg/svgs/credit-card.icon.ts index e334766fac7..dd0eb6a121a 100644 --- a/libs/assets/src/svg/svgs/credit-card.icon.ts +++ b/libs/assets/src/svg/svgs/credit-card.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const CreditCardIcon = svgIcon` +export const CreditCardIcon = svg` diff --git a/libs/assets/src/svg/svgs/deactivated-org.ts b/libs/assets/src/svg/svgs/deactivated-org.ts index 75b25e3fd27..d2566712a98 100644 --- a/libs/assets/src/svg/svgs/deactivated-org.ts +++ b/libs/assets/src/svg/svgs/deactivated-org.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DeactivatedOrg = svgIcon` +export const DeactivatedOrg = svg` diff --git a/libs/assets/src/svg/svgs/devices.icon.ts b/libs/assets/src/svg/svgs/devices.icon.ts index 7c97df48657..a3a4aa06442 100644 --- a/libs/assets/src/svg/svgs/devices.icon.ts +++ b/libs/assets/src/svg/svgs/devices.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DevicesIcon = svgIcon` +export const DevicesIcon = svg` diff --git a/libs/assets/src/svg/svgs/domain.icon.ts b/libs/assets/src/svg/svgs/domain.icon.ts index 04bd173be98..af47b1930d7 100644 --- a/libs/assets/src/svg/svgs/domain.icon.ts +++ b/libs/assets/src/svg/svgs/domain.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DomainIcon = svgIcon` +export const DomainIcon = svg` diff --git a/libs/assets/src/svg/svgs/empty-trash.ts b/libs/assets/src/svg/svgs/empty-trash.ts index d6c0043d880..da48bd69c3e 100644 --- a/libs/assets/src/svg/svgs/empty-trash.ts +++ b/libs/assets/src/svg/svgs/empty-trash.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const EmptyTrash = svgIcon` +export const EmptyTrash = svg` diff --git a/libs/assets/src/svg/svgs/favorites.icon.ts b/libs/assets/src/svg/svgs/favorites.icon.ts index 4725d0b0a7c..8777eaeef88 100644 --- a/libs/assets/src/svg/svgs/favorites.icon.ts +++ b/libs/assets/src/svg/svgs/favorites.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const FavoritesIcon = svgIcon` +export const FavoritesIcon = svg` diff --git a/libs/assets/src/svg/svgs/gear.ts b/libs/assets/src/svg/svgs/gear.ts index 261c6d262e1..c04dc8e1a17 100644 --- a/libs/assets/src/svg/svgs/gear.ts +++ b/libs/assets/src/svg/svgs/gear.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const GearIcon = svgIcon` +export const GearIcon = svg` diff --git a/libs/assets/src/svg/svgs/generator.ts b/libs/assets/src/svg/svgs/generator.ts index 52368ddc204..26b09f19455 100644 --- a/libs/assets/src/svg/svgs/generator.ts +++ b/libs/assets/src/svg/svgs/generator.ts @@ -1,12 +1,12 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const GeneratorInactive = svgIcon` +export const GeneratorInactive = svg` `; -export const GeneratorActive = svgIcon` +export const GeneratorActive = svg` diff --git a/libs/assets/src/svg/svgs/item-types.ts b/libs/assets/src/svg/svgs/item-types.ts index 50ed51bd018..b066df72b0d 100644 --- a/libs/assets/src/svg/svgs/item-types.ts +++ b/libs/assets/src/svg/svgs/item-types.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ItemTypes = svgIcon` +export const ItemTypes = svg` diff --git a/libs/assets/src/svg/svgs/lock.icon.ts b/libs/assets/src/svg/svgs/lock.icon.ts index 9d73ad6294c..f42630739f1 100644 --- a/libs/assets/src/svg/svgs/lock.icon.ts +++ b/libs/assets/src/svg/svgs/lock.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const LockIcon = svgIcon` +export const LockIcon = svg` diff --git a/libs/assets/src/svg/svgs/login-cards.ts b/libs/assets/src/svg/svgs/login-cards.ts index 3a43b1a0121..13c456a1658 100644 --- a/libs/assets/src/svg/svgs/login-cards.ts +++ b/libs/assets/src/svg/svgs/login-cards.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const LoginCards = svgIcon` +export const LoginCards = svg` diff --git a/libs/assets/src/svg/svgs/no-credentials.icon.ts b/libs/assets/src/svg/svgs/no-credentials.icon.ts index bfecfd4834c..da7795db808 100644 --- a/libs/assets/src/svg/svgs/no-credentials.icon.ts +++ b/libs/assets/src/svg/svgs/no-credentials.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoCredentialsIcon = svgIcon` +export const NoCredentialsIcon = svg` diff --git a/libs/assets/src/svg/svgs/no-folders.ts b/libs/assets/src/svg/svgs/no-folders.ts index c8858ca83e5..7facc01e4d6 100644 --- a/libs/assets/src/svg/svgs/no-folders.ts +++ b/libs/assets/src/svg/svgs/no-folders.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoFolders = svgIcon` +export const NoFolders = svg` diff --git a/libs/assets/src/svg/svgs/no-results.ts b/libs/assets/src/svg/svgs/no-results.ts index 5f914ad213c..75ad485181f 100644 --- a/libs/assets/src/svg/svgs/no-results.ts +++ b/libs/assets/src/svg/svgs/no-results.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoResults = svgIcon` +export const NoResults = svg` diff --git a/libs/assets/src/svg/svgs/no-send.icon.ts b/libs/assets/src/svg/svgs/no-send.icon.ts index a246c0177f8..a7125caabf6 100644 --- a/libs/assets/src/svg/svgs/no-send.icon.ts +++ b/libs/assets/src/svg/svgs/no-send.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoSendsIcon = svgIcon` +export const NoSendsIcon = svg` diff --git a/libs/assets/src/svg/svgs/party.ts b/libs/assets/src/svg/svgs/party.ts index efa5331f4fc..991f4a3deda 100644 --- a/libs/assets/src/svg/svgs/party.ts +++ b/libs/assets/src/svg/svgs/party.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const Party = svgIcon` +export const Party = svg` diff --git a/libs/assets/src/svg/svgs/password-manager.ts b/libs/assets/src/svg/svgs/password-manager.ts index 5b19562e022..aa7e8ecc52d 100644 --- a/libs/assets/src/svg/svgs/password-manager.ts +++ b/libs/assets/src/svg/svgs/password-manager.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const PasswordManagerLogo = svgIcon` +const PasswordManagerLogo = svg` diff --git a/libs/assets/src/svg/svgs/provider-portal.ts b/libs/assets/src/svg/svgs/provider-portal.ts index fad2ce6b864..97d23633a9e 100644 --- a/libs/assets/src/svg/svgs/provider-portal.ts +++ b/libs/assets/src/svg/svgs/provider-portal.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const ProviderPortalLogo = svgIcon` +const ProviderPortalLogo = svg` diff --git a/libs/assets/src/svg/svgs/registration-check-email.icon.ts b/libs/assets/src/svg/svgs/registration-check-email.icon.ts index ae4cf3098e6..006a60bc7c0 100644 --- a/libs/assets/src/svg/svgs/registration-check-email.icon.ts +++ b/libs/assets/src/svg/svgs/registration-check-email.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RegistrationCheckEmailIcon = svgIcon` +export const RegistrationCheckEmailIcon = svg` diff --git a/libs/assets/src/svg/svgs/registration-user-add.icon.ts b/libs/assets/src/svg/svgs/registration-user-add.icon.ts index 7428daa5848..358412c38eb 100644 --- a/libs/assets/src/svg/svgs/registration-user-add.icon.ts +++ b/libs/assets/src/svg/svgs/registration-user-add.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RegistrationUserAddIcon = svgIcon` +export const RegistrationUserAddIcon = svg` diff --git a/libs/assets/src/svg/svgs/report-breach.icon.ts b/libs/assets/src/svg/svgs/report-breach.icon.ts index 83dd6c72b82..e926388e333 100644 --- a/libs/assets/src/svg/svgs/report-breach.icon.ts +++ b/libs/assets/src/svg/svgs/report-breach.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportBreach = svgIcon` +export const ReportBreach = svg` diff --git a/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts b/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts index 0309eb643d9..590e7d7d1a1 100644 --- a/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts +++ b/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportExposedPasswords = svgIcon` +export const ReportExposedPasswords = svg` diff --git a/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts b/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts index 487381ccaa9..831a6570812 100644 --- a/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts +++ b/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportUnsecuredWebsites = svgIcon` +export const ReportUnsecuredWebsites = svg` diff --git a/libs/assets/src/svg/svgs/restricted-view.ts b/libs/assets/src/svg/svgs/restricted-view.ts index 5eec1a4a972..7bf40467ac6 100644 --- a/libs/assets/src/svg/svgs/restricted-view.ts +++ b/libs/assets/src/svg/svgs/restricted-view.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RestrictedView = svgIcon` +export const RestrictedView = svg` diff --git a/libs/assets/src/svg/svgs/secrets-manager-alt.ts b/libs/assets/src/svg/svgs/secrets-manager-alt.ts index 98640803ca9..70fa7d6386c 100644 --- a/libs/assets/src/svg/svgs/secrets-manager-alt.ts +++ b/libs/assets/src/svg/svgs/secrets-manager-alt.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SecretsManagerAlt = svgIcon` +export const SecretsManagerAlt = svg` diff --git a/libs/assets/src/svg/svgs/secrets-manager.ts b/libs/assets/src/svg/svgs/secrets-manager.ts index 62b54174c55..3cd66df59e3 100644 --- a/libs/assets/src/svg/svgs/secrets-manager.ts +++ b/libs/assets/src/svg/svgs/secrets-manager.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const SecretsManagerLogo = svgIcon` +const SecretsManagerLogo = svg` diff --git a/libs/assets/src/svg/svgs/security.ts b/libs/assets/src/svg/svgs/security.ts index 6e475b25ab7..119d0164599 100644 --- a/libs/assets/src/svg/svgs/security.ts +++ b/libs/assets/src/svg/svgs/security.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const Security = svgIcon` +export const Security = svg` diff --git a/libs/assets/src/svg/svgs/send.ts b/libs/assets/src/svg/svgs/send.ts index f09f59a5388..309844f9fd9 100644 --- a/libs/assets/src/svg/svgs/send.ts +++ b/libs/assets/src/svg/svgs/send.ts @@ -1,12 +1,12 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SendInactive = svgIcon` +export const SendInactive = svg` `; -export const SendActive = svgIcon` +export const SendActive = svg` diff --git a/libs/assets/src/svg/svgs/settings.ts b/libs/assets/src/svg/svgs/settings.ts index 3b54bbbd88c..b0e42821c6b 100644 --- a/libs/assets/src/svg/svgs/settings.ts +++ b/libs/assets/src/svg/svgs/settings.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SettingsInactive = svgIcon` +export const SettingsInactive = svg` `; -export const SettingsActive = svgIcon` +export const SettingsActive = svg` diff --git a/libs/assets/src/svg/svgs/shield.ts b/libs/assets/src/svg/svgs/shield.ts index af626a98e9d..bd5f9e02d1d 100644 --- a/libs/assets/src/svg/svgs/shield.ts +++ b/libs/assets/src/svg/svgs/shield.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const BitwardenShield = svgIcon` +const BitwardenShield = svg` diff --git a/libs/assets/src/svg/svgs/sso-key.icon.ts b/libs/assets/src/svg/svgs/sso-key.icon.ts index ad81c707449..d6e45b13b42 100644 --- a/libs/assets/src/svg/svgs/sso-key.icon.ts +++ b/libs/assets/src/svg/svgs/sso-key.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SsoKeyIcon = svgIcon` +export const SsoKeyIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts index 622875b59f2..11d2fafb745 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthAuthenticatorIcon = svgIcon` +export const TwoFactorAuthAuthenticatorIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts index 5bf43334d18..a40a6418885 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts @@ -1,8 +1,10 @@ -// this svg includes the Duo logo, which contains colors not part of our bitwarden theme colors /* eslint-disable @bitwarden/components/require-theme-colors-in-svg */ -import { svgIcon } from "../icon-service"; -export const TwoFactorAuthDuoIcon = svgIcon` +// this svg includes the Duo logo, which contains colors not part of our bitwarden theme colors + +import { svg } from "../svg"; + +export const TwoFactorAuthDuoIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts index 20709a8a1e1..8fdee85da82 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthEmailIcon = svgIcon` +export const TwoFactorAuthEmailIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts index 0e467bf1901..3eab3bb00c6 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthSecurityKeyFailedIcon = svgIcon` +export const TwoFactorAuthSecurityKeyFailedIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts index f10068b735b..830db83f3e8 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthSecurityKeyIcon = svgIcon` +export const TwoFactorAuthSecurityKeyIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts index b9114259584..9f0decb1f36 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthWebAuthnIcon = svgIcon` +export const TwoFactorAuthWebAuthnIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts index d4d38c363ae..6368442cde6 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts @@ -1,8 +1,9 @@ -// this svg includes the Yubico logo, which contains colors not part of our bitwarden theme colors /* eslint-disable @bitwarden/components/require-theme-colors-in-svg */ -import { svgIcon } from "../icon-service"; +// this svg includes the Yubico logo, which contains colors not part of our bitwarden theme colors -export const TwoFactorAuthYubicoIcon = svgIcon` +import { svg } from "../svg"; + +export const TwoFactorAuthYubicoIcon = svg` diff --git a/libs/assets/src/svg/svgs/unlocked.icon.ts b/libs/assets/src/svg/svgs/unlocked.icon.ts index 6ce40819e44..1a754733d26 100644 --- a/libs/assets/src/svg/svgs/unlocked.icon.ts +++ b/libs/assets/src/svg/svgs/unlocked.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UnlockedIcon = svgIcon` +export const UnlockedIcon = svg` diff --git a/libs/assets/src/svg/svgs/user-lock.icon.ts b/libs/assets/src/svg/svgs/user-lock.icon.ts index cc848a05769..5deead382b3 100644 --- a/libs/assets/src/svg/svgs/user-lock.icon.ts +++ b/libs/assets/src/svg/svgs/user-lock.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UserLockIcon = svgIcon` +export const UserLockIcon = svg` diff --git a/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts b/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts index 19e1aa3e6cd..c175bb78993 100644 --- a/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts +++ b/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UserVerificationBiometricsIcon = svgIcon` +export const UserVerificationBiometricsIcon = svg` diff --git a/libs/assets/src/svg/svgs/vault-open.ts b/libs/assets/src/svg/svgs/vault-open.ts index 3ad82b9bbac..52e8a971d60 100644 --- a/libs/assets/src/svg/svgs/vault-open.ts +++ b/libs/assets/src/svg/svgs/vault-open.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultOpen = svgIcon` +export const VaultOpen = svg` diff --git a/libs/assets/src/svg/svgs/vault.icon.ts b/libs/assets/src/svg/svgs/vault.icon.ts index 61ec2589b34..1f442ad0471 100644 --- a/libs/assets/src/svg/svgs/vault.icon.ts +++ b/libs/assets/src/svg/svgs/vault.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultIcon = svgIcon` +export const VaultIcon = svg` diff --git a/libs/assets/src/svg/svgs/vault.ts b/libs/assets/src/svg/svgs/vault.ts index 1c699f2ba8e..8e1acab2670 100644 --- a/libs/assets/src/svg/svgs/vault.ts +++ b/libs/assets/src/svg/svgs/vault.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultInactive = svgIcon` +export const VaultInactive = svg` `; -export const VaultActive = svgIcon` +export const VaultActive = svg` diff --git a/libs/assets/src/svg/svgs/wave.icon.ts b/libs/assets/src/svg/svgs/wave.icon.ts index 6c97d0fbbb3..7b00ba0f3eb 100644 --- a/libs/assets/src/svg/svgs/wave.icon.ts +++ b/libs/assets/src/svg/svgs/wave.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const WaveIcon = svgIcon` +export const WaveIcon = svg` diff --git a/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts b/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts index e7a3e99759c..87b5173a6a7 100644 --- a/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts +++ b/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts @@ -9,7 +9,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { TwoFactorTimeoutIcon } from "@bitwarden/assets/svg"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { ButtonModule, IconModule } from "@bitwarden/components"; +import { ButtonModule, SvgModule } from "@bitwarden/components"; /** * RegistrationLinkExpiredComponentData @@ -24,7 +24,7 @@ export interface RegistrationLinkExpiredComponentData { @Component({ selector: "auth-registration-link-expired", templateUrl: "./registration-link-expired.component.html", - imports: [CommonModule, JslibModule, RouterModule, IconModule, ButtonModule], + imports: [CommonModule, JslibModule, RouterModule, SvgModule, ButtonModule], }) export class RegistrationLinkExpiredComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts index 714f6d49342..1161af836b4 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts +++ b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts @@ -20,7 +20,7 @@ import { ButtonModule, CheckboxModule, FormFieldModule, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -54,7 +54,7 @@ const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record = { CheckboxModule, ButtonModule, LinkModule, - IconModule, + SvgModule, RegistrationEnvSelectorComponent, ], }) diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html index 277ba047add..bf9482c7987 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html @@ -11,30 +11,30 @@ [ngSwitch]="provider.type" class="tw-w-16 md:tw-w-20 tw-mr-2 sm:tw-mr-4" > - - + - + - + - + - + + [content]="Icons.TwoFactorAuthWebAuthnIcon" + >
    {{ provider.name }} {{ provider.description }} diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts index d8b2ab2508b..53ae509f182 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts @@ -18,7 +18,7 @@ import { ButtonModule, DialogModule, DialogService, - IconModule, + SvgModule, ItemModule, TypographyModule, } from "@bitwarden/components"; @@ -39,7 +39,7 @@ export type TwoFactorOptionsDialogResult = { ButtonModule, TypographyModule, ItemModule, - IconModule, + SvgModule, ], providers: [], }) diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html index 5699f3dd9a4..8e8f41c394d 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html @@ -42,7 +42,7 @@ >
    - +

    {{ "verifyWithBiometrics" | i18n }}

    diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts index 296359c92ff..af73cc3de99 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts @@ -28,7 +28,7 @@ import { CalloutModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -64,7 +64,7 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt FormFieldModule, AsyncActionsModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, ButtonModule, CalloutModule, diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 84140a8953a..b8f8851864b 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -3,7 +3,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, of, switchMap, tap } from "rxjs"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Translation } from "../dialog"; @@ -27,7 +27,7 @@ export interface AnonLayoutWrapperData { /** * The icon to display on the page. Pass null to hide the icon. */ - pageIcon: Icon | null; + pageIcon: BitSvg | null; /** * Optional flag to either show the optional environment selector (false) or just a readonly hostname (true). */ @@ -57,7 +57,7 @@ export class AnonLayoutWrapperComponent implements OnInit { protected pageTitle?: string | null; protected pageSubtitle?: string | null; - protected pageIcon: Icon | null = null; + protected pageIcon: BitSvg | null = null; protected showReadonlyHostname?: boolean | null; protected maxWidth?: LandingContentMaxWidthType | null; protected hideCardWrapper?: boolean | null; diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index eded556cd53..953a5e769cf 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -11,15 +11,15 @@ import { import { RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; -import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; +import { BitwardenLogo, BitSvg } from "@bitwarden/assets/svg"; import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { IconModule } from "../icon"; import { LandingContentMaxWidthType } from "../landing-layout"; import { LandingLayoutModule } from "../landing-layout/landing-layout.module"; import { SharedModule } from "../shared"; +import { SvgModule } from "../svg"; import { TypographyModule } from "../typography"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -28,7 +28,7 @@ import { TypographyModule } from "../typography"; selector: "auth-anon-layout", templateUrl: "./anon-layout.component.html", imports: [ - IconModule, + SvgModule, CommonModule, TypographyModule, SharedModule, @@ -45,7 +45,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { readonly title = input(); readonly subtitle = input(); - readonly icon = model.required(); + readonly icon = model.required(); readonly showReadonlyHostname = input(false); readonly hideLogo = input(false); readonly hideFooter = input(false); diff --git a/libs/components/src/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts index 01cdc04ad73..ed6df181c85 100644 --- a/libs/components/src/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -2,7 +2,7 @@ import { ActivatedRoute, RouterModule } from "@angular/router"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; -import { Icon, LockIcon } from "@bitwarden/assets/svg"; +import { BitSvg, LockIcon } from "@bitwarden/assets/svg"; import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -23,7 +23,7 @@ type StoryArgs = AnonLayoutComponent & { contentLength: "normal" | "long" | "thin"; showSecondary: boolean; useDefaultIcon: boolean; - icon: Icon; + icon: BitSvg; includeHeaderActions: boolean; }; diff --git a/libs/components/src/callout/callout.stories.ts b/libs/components/src/callout/callout.stories.ts index c2185203034..ff1a8c16d5f 100644 --- a/libs/components/src/callout/callout.stories.ts +++ b/libs/components/src/callout/callout.stories.ts @@ -1,7 +1,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LinkModule, IconModule } from "@bitwarden/components"; +import { LinkModule, SvgModule } from "@bitwarden/components"; import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; import { I18nMockService } from "../utils/i18n-mock.service"; @@ -13,7 +13,7 @@ export default { component: CalloutComponent, decorators: [ moduleMetadata({ - imports: [LinkModule, IconModule], + imports: [LinkModule, SvgModule], providers: [ { provide: I18nService, diff --git a/libs/components/src/header/header.stories.ts b/libs/components/src/header/header.stories.ts index 620f39a5dc3..23c2bb2edb5 100644 --- a/libs/components/src/header/header.stories.ts +++ b/libs/components/src/header/header.stories.ts @@ -14,7 +14,7 @@ import { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, @@ -40,7 +40,7 @@ export default { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, diff --git a/libs/components/src/icon/icon.component.ts b/libs/components/src/icon/icon.component.ts index f57a3627383..c2dc468dc71 100644 --- a/libs/components/src/icon/icon.component.ts +++ b/libs/components/src/icon/icon.component.ts @@ -1,35 +1,30 @@ -import { Component, effect, input } from "@angular/core"; -import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; -import { Icon, isIcon } from "@bitwarden/assets/svg"; +import { BitwardenIcon } from "../shared/icon"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-icon", + standalone: true, host: { - "[attr.aria-hidden]": "!ariaLabel()", + "[class]": "classList()", + "[attr.aria-hidden]": "ariaLabel() ? null : true", "[attr.aria-label]": "ariaLabel()", - "[innerHtml]": "innerHtml", - class: "tw-max-h-full tw-flex tw-justify-center", }, template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BitIconComponent { - innerHtml: SafeHtml | null = null; - - readonly icon = input(); +export class IconComponent { + /** + * The Bitwarden icon name (e.g., "bwi-lock", "bwi-user") + */ + readonly name = input.required(); + /** + * Accessible label for the icon + */ readonly ariaLabel = input(); - constructor(private domSanitizer: DomSanitizer) { - effect(() => { - const icon = this.icon(); - if (!isIcon(icon)) { - return; - } - const svg = icon.svg; - this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg); - }); - } + protected readonly classList = computed(() => { + return ["bwi", this.name()].join(" "); + }); } diff --git a/libs/components/src/icon/icon.mdx b/libs/components/src/icon/icon.mdx index 4f6f13c895e..0914d681e59 100644 --- a/libs/components/src/icon/icon.mdx +++ b/libs/components/src/icon/icon.mdx @@ -8,113 +8,40 @@ import * as stories from "./icon.stories"; import { IconModule } from "@bitwarden/components"; ``` -# Icon Use Instructions +# Icon -- Icons will generally be attached to the associated Jira task. - - Designers should minify any SVGs before attaching them to Jira using a tool like - [SVGOMG](https://jakearchibald.github.io/svgomg/). - - **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon - is desired. +The `bit-icon` component renders Bitwarden Web Icons (bwi) using icon font classes. -## Developer Instructions +## Basic Usage -1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice. - - The SVG should be formatted using either a built-in formatter or an external tool like - [SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying - classes easier. +```html + +``` -2. **Rename the file** as a `.icon.ts` TypeScript file and place it in the `libs/assets/svg` - lib. +## Icon Names -3. **Import** `svgIcon` from `./icon-service`. +All available icon names are defined in the `BitwardenIcon` type. Icons use the `bwi-*` naming +convention (e.g., `bwi-lock`, `bwi-user`, `bwi-key`). -4. **Define and export** a `const` to represent your `svgIcon`. +## Accessibility - ```typescript - export const ExampleIcon = svgIcon``; - ``` +By default, icons are decorative and marked with `aria-hidden="true"`. To make an icon accessible, +provide an `ariaLabel`: -5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class. - - **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when - styling the inside of an SVG path. +```html + +``` - - A non-comprehensive list of common colors and their associated classes is below: +## Styling - | Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable | - | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------- | - | `#020F66` | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | - | `#DBE5F6` | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | - | `#AAC3EF` | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | - | `#FFFFFF` | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | - | `#FFBF00` | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | - | `#175DDC` | `tw-stroke-illustration-logo` | `tw-fill-illustration-logo` | `--color-illustration-logo` | +The component renders as an inline element. Apply standard CSS classes or styles to customize +appearance: - - If the hex that you have on an SVG path is not listed above, there are a few ways to figure out - the appropriate Tailwind class: - - **Option 1: Figma** - - Open the SVG in Figma. - - Click on an individual path on the SVG until you see the path's properties in the - right-hand panel. - - Scroll down to the Colors section. - - Example: `Color/Illustration/Outline` - - This also includes Hex or RGB values that can be used to find the appropriate Tailwind - variable as well if you follow the manual search option below. - - Create the appropriate stroke or fill class from the color used. - - Example: `Color/Illustration/Outline` corresponds to `--color-illustration-outline` which - corresponds to `tw-stroke-illustration-outline` or `tw-fill-illustration-outline`. - - **Option 2: Manual Search** - - Take the path's stroke or fill hex value and convert it to RGB using a tool like - [Hex to RGB](https://www.rgbtohex.net/hex-to-rgb/). - - Search for the RGB value without commas in our `tw-theme.css` to find the Tailwind variable - that corresponds to the color. - - Create the appropriate stroke or fill class using the Tailwind variable. - - Example: `--color-illustration-outline` corresponds to `tw-stroke-illustration-outline` - or `tw-fill-illustration-outline`. +```html + +``` -6. **Remove any hardcoded width or height attributes** if your SVG has a configured - [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order - to allow the SVG to scale to fit its container. - - **Note:** Scaling is required for any SVG used as an - [AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`. +## Note on SVG Icons -7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the - referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`). - -8. **Import your SVG const** anywhere you want to use the SVG. - - **Angular Component Example:** - - **TypeScript:** - - ```typescript - import { Component } from "@angular/core"; - import { IconModule } from '@bitwarden/components'; - import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg"; - - @Component({ - selector: "app-example", - standalone: true, - imports: [IconModule], - templateUrl: "./example.component.html", - }) - export class ExampleComponent { - readonly Icons = { ExampleIcon, Example2Icon }; - ... - } - ``` - - - **HTML:** - - > NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an - > `ariaLabel` is explicitly provided to the `` component - - ```html - - ``` - - With `ariaLabel` - - ```html - - ``` - -9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client - which supports multiple style modes. +For SVG illustrations (not font icons), use the `bit-svg` component instead. See the Svg component +documentation for details. diff --git a/libs/components/src/icon/icon.module.ts b/libs/components/src/icon/icon.module.ts index 3d15b5bb3c3..b3e65619bd3 100644 --- a/libs/components/src/icon/icon.module.ts +++ b/libs/components/src/icon/icon.module.ts @@ -1,9 +1,9 @@ import { NgModule } from "@angular/core"; -import { BitIconComponent } from "./icon.component"; +import { IconComponent } from "./icon.component"; @NgModule({ - imports: [BitIconComponent], - exports: [BitIconComponent], + imports: [IconComponent], + exports: [IconComponent], }) export class IconModule {} diff --git a/libs/components/src/icon/icon.stories.ts b/libs/components/src/icon/icon.stories.ts index e94a7aaf51c..5626407ea51 100644 --- a/libs/components/src/icon/icon.stories.ts +++ b/libs/components/src/icon/icon.stories.ts @@ -1,50 +1,61 @@ -import { Meta } from "@storybook/angular"; +import { Meta, StoryObj } from "@storybook/angular"; -import * as SvgIcons from "@bitwarden/assets/svg"; +import { BITWARDEN_ICONS } from "../shared/icon"; -import { BitIconComponent } from "./icon.component"; +import { IconComponent } from "./icon.component"; export default { title: "Component Library/Icon", - component: BitIconComponent, + component: IconComponent, parameters: { design: { type: "figma", url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11", }, }, -} as Meta; + argTypes: { + name: { + control: { type: "select" }, + options: BITWARDEN_ICONS, + }, + }, +} as Meta; -const { - // Filtering out the few non-icons in the libs/assets/svg import - // eslint-disable-next-line @typescript-eslint/no-unused-vars - DynamicContentNotAllowedError: _DynamicContentNotAllowedError, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isIcon, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - svgIcon, - ...Icons -}: { - [key: string]: any; -} = SvgIcons; +type Story = StoryObj; -export const Default = { - render: (args: { icons: [string, any][] }) => ({ - props: args, - template: /*html*/ ` -
    - @for (icon of icons; track icon[0]) { -
    -
    {{icon[0]}}
    -
    - -
    -
    - } -
    - `, - }), +export const Default: Story = { args: { - icons: Object.entries(Icons), + name: "bwi-lock", }, }; + +export const AllIcons: Story = { + render: () => ({ + template: ` +
    + @for (icon of icons; track icon) { +
    + + {{ icon }} +
    + } +
    + `, + props: { + icons: BITWARDEN_ICONS, + }, + }), +}; + +export const WithAriaLabel: Story = { + args: { + name: "bwi-lock", + ariaLabel: "Secure lock icon", + }, +}; + +export const CompareWithLegacy: Story = { + render: () => ({ + template: ` `, + }), +}; diff --git a/libs/components/src/icon/index.ts b/libs/components/src/icon/index.ts index 1ee66e59837..670966a7630 100644 --- a/libs/components/src/icon/index.ts +++ b/libs/components/src/icon/index.ts @@ -1 +1,2 @@ export * from "./icon.module"; +export * from "./icon.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 9c4dadadd4b..80fd6fc05a6 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -22,6 +22,7 @@ export * from "./form-field"; export * from "./header"; export * from "./icon-button"; export * from "./icon"; +export * from "./svg"; export * from "./icon-tile"; export * from "./input"; export * from "./item"; diff --git a/libs/components/src/landing-layout/landing-header.component.html b/libs/components/src/landing-layout/landing-header.component.html index ed6d34ef23b..882f1b96c99 100644 --- a/libs/components/src/landing-layout/landing-header.component.html +++ b/libs/components/src/landing-layout/landing-header.component.html @@ -4,7 +4,7 @@ [routerLink]="['/']" class="tw-w-32 tw-py-5 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top" > - + }
    diff --git a/libs/components/src/landing-layout/landing-header.component.ts b/libs/components/src/landing-layout/landing-header.component.ts index eb5329e915d..c0fb3cd67f1 100644 --- a/libs/components/src/landing-layout/landing-header.component.ts +++ b/libs/components/src/landing-layout/landing-header.component.ts @@ -3,8 +3,8 @@ import { RouterModule } from "@angular/router"; import { BitwardenLogo } from "@bitwarden/assets/svg"; -import { IconModule } from "../icon"; import { SharedModule } from "../shared"; +import { SvgModule } from "../svg"; /** * Header component for landing pages with optional Bitwarden logo and header actions slot. @@ -34,7 +34,7 @@ import { SharedModule } from "../shared"; selector: "bit-landing-header", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-header.component.html", - imports: [RouterModule, IconModule, SharedModule], + imports: [RouterModule, SvgModule, SharedModule], }) export class LandingHeaderComponent { readonly hideLogo = input(false); diff --git a/libs/components/src/landing-layout/landing-hero.component.html b/libs/components/src/landing-layout/landing-hero.component.html index dbce6a7c585..9394bb03c63 100644 --- a/libs/components/src/landing-layout/landing-hero.component.html +++ b/libs/components/src/landing-layout/landing-hero.component.html @@ -6,7 +6,7 @@
    - +
    } diff --git a/libs/components/src/landing-layout/landing-hero.component.ts b/libs/components/src/landing-layout/landing-hero.component.ts index b29e9768efd..d3b9ffd0ee9 100644 --- a/libs/components/src/landing-layout/landing-hero.component.ts +++ b/libs/components/src/landing-layout/landing-hero.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, input } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; -import { IconModule } from "../icon"; +import { SvgModule } from "../svg"; import { TypographyModule } from "../typography"; /** @@ -31,10 +31,10 @@ import { TypographyModule } from "../typography"; selector: "bit-landing-hero", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-hero.component.html", - imports: [IconModule, TypographyModule], + imports: [SvgModule, TypographyModule], }) export class LandingHeroComponent { - readonly icon = input(null); + readonly icon = input(null); readonly title = input(); readonly subtitle = input(); } diff --git a/libs/components/src/landing-layout/landing-layout.component.html b/libs/components/src/landing-layout/landing-layout.component.html index 1164f538116..a33054e8e64 100644 --- a/libs/components/src/landing-layout/landing-layout.component.html +++ b/libs/components/src/landing-layout/landing-layout.component.html @@ -13,12 +13,12 @@
    - +
    - +
    } diff --git a/libs/components/src/landing-layout/landing-layout.component.ts b/libs/components/src/landing-layout/landing-layout.component.ts index 520cca945d6..65c7302e828 100644 --- a/libs/components/src/landing-layout/landing-layout.component.ts +++ b/libs/components/src/landing-layout/landing-layout.component.ts @@ -3,7 +3,7 @@ import { Component, ChangeDetectionStrategy, inject, input } from "@angular/core import { BackgroundLeftIllustration, BackgroundRightIllustration } from "@bitwarden/assets/svg"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { IconModule } from "../icon"; +import { SvgModule } from "../svg"; /** * Root layout component for landing pages providing a full-screen container with optional decorative background illustrations. @@ -27,7 +27,7 @@ import { IconModule } from "../icon"; selector: "bit-landing-layout", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-layout.component.html", - imports: [IconModule], + imports: [SvgModule], }) export class LandingLayoutComponent { readonly hideBackgroundIllustration = input(false); diff --git a/libs/components/src/navigation/nav-logo.component.html b/libs/components/src/navigation/nav-logo.component.html index 9f18855ae13..8323a0f3479 100644 --- a/libs/components/src/navigation/nav-logo.component.html +++ b/libs/components/src/navigation/nav-logo.component.html @@ -16,6 +16,6 @@ routerLinkActive ariaCurrentWhenActive="page" > - +
    diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts index fec50ee8902..4b3dc471edb 100644 --- a/libs/components/src/navigation/nav-logo.component.ts +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -1,16 +1,16 @@ import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core"; import { RouterLinkActive, RouterLink } from "@angular/router"; -import { BitwardenShield, Icon } from "@bitwarden/assets/svg"; +import { BitwardenShield, BitSvg } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "../icon/icon.component"; +import { SvgComponent } from "../svg/svg.component"; import { SideNavService } from "./side-nav.service"; @Component({ selector: "bit-nav-logo", templateUrl: "./nav-logo.component.html", - imports: [RouterLinkActive, RouterLink, BitIconComponent], + imports: [RouterLinkActive, RouterLink, SvgComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavLogoComponent { @@ -26,7 +26,7 @@ export class NavLogoComponent { /** * Icon that is displayed when the side nav is open */ - readonly openIcon = input.required(); + readonly openIcon = input.required(); /** * Route to be passed to internal `routerLink` diff --git a/libs/components/src/no-items/no-items.component.html b/libs/components/src/no-items/no-items.component.html index e728584a41a..46a5c25526a 100644 --- a/libs/components/src/no-items/no-items.component.html +++ b/libs/components/src/no-items/no-items.component.html @@ -1,7 +1,7 @@
    - +

    diff --git a/libs/components/src/no-items/no-items.component.ts b/libs/components/src/no-items/no-items.component.ts index c6e52a1f83d..d2cacfd2251 100644 --- a/libs/components/src/no-items/no-items.component.ts +++ b/libs/components/src/no-items/no-items.component.ts @@ -1,18 +1,17 @@ -import { Component, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { NoResults } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "../icon/icon.component"; +import { SvgComponent } from "../svg/svg.component"; /** * Component for displaying a message when there are no items to display. Expects title, description and button slots. */ -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-no-items", templateUrl: "./no-items.component.html", - imports: [BitIconComponent], + imports: [SvgComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NoItemsComponent { readonly icon = input(NoResults); diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts index c4fe2f9b2af..398251fd2e2 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts @@ -16,7 +16,6 @@ import { DialogModule } from "../../dialog"; import { DrawerModule } from "../../drawer"; import { FormControlModule } from "../../form-control"; import { FormFieldModule } from "../../form-field"; -import { IconModule } from "../../icon"; import { IconButtonModule } from "../../icon-button"; import { InputModule } from "../../input"; import { LayoutComponent } from "../../layout"; @@ -31,6 +30,7 @@ import { SearchModule } from "../../search"; import { SectionComponent } from "../../section"; import { SelectModule } from "../../select"; import { SharedModule } from "../../shared"; +import { SvgModule } from "../../svg"; import { TableModule } from "../../table"; import { TabsModule } from "../../tabs"; import { ToggleGroupModule } from "../../toggle-group"; @@ -54,7 +54,7 @@ import { TypographyModule } from "../../typography"; FormFieldModule, FormsModule, IconButtonModule, - IconModule, + SvgModule, InputModule, LayoutComponent, LinkModule, @@ -92,7 +92,7 @@ import { TypographyModule } from "../../typography"; FormFieldModule, FormsModule, IconButtonModule, - IconModule, + SvgModule, InputModule, LayoutComponent, LinkModule, diff --git a/libs/components/src/svg/index.ts b/libs/components/src/svg/index.ts new file mode 100644 index 00000000000..ae4c480e786 --- /dev/null +++ b/libs/components/src/svg/index.ts @@ -0,0 +1,2 @@ +export * from "./svg.module"; +export * from "./svg.component"; diff --git a/libs/components/src/svg/svg.component.ts b/libs/components/src/svg/svg.component.ts new file mode 100644 index 00000000000..bcb63cfa568 --- /dev/null +++ b/libs/components/src/svg/svg.component.ts @@ -0,0 +1,31 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core"; +import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; + +import { BitSvg, isBitSvg } from "@bitwarden/assets/svg"; + +@Component({ + selector: "bit-svg", + host: { + "[attr.aria-hidden]": "!ariaLabel()", + "[attr.aria-label]": "ariaLabel()", + "[innerHtml]": "innerHtml()", + class: "tw-max-h-full tw-flex tw-justify-center", + }, + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SvgComponent { + private domSanitizer = inject(DomSanitizer); + + readonly content = input(); + readonly ariaLabel = input(); + + protected readonly innerHtml = computed(() => { + const content = this.content(); + if (!isBitSvg(content)) { + return null; + } + const svg = content.svg; + return this.domSanitizer.bypassSecurityTrustHtml(svg); + }); +} diff --git a/libs/components/src/icon/icon.components.spec.ts b/libs/components/src/svg/svg.components.spec.ts similarity index 55% rename from libs/components/src/icon/icon.components.spec.ts rename to libs/components/src/svg/svg.components.spec.ts index 3ae37ff5423..55874d29e6c 100644 --- a/libs/components/src/icon/icon.components.spec.ts +++ b/libs/components/src/svg/svg.components.spec.ts @@ -1,25 +1,25 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { Icon, svgIcon } from "@bitwarden/assets/svg"; +import { BitSvg, svg } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "./icon.component"; +import { SvgComponent } from "./svg.component"; -describe("IconComponent", () => { - let fixture: ComponentFixture; +describe("SvgComponent", () => { + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [BitIconComponent], + imports: [SvgComponent], }).compileComponents(); - fixture = TestBed.createComponent(BitIconComponent); + fixture = TestBed.createComponent(SvgComponent); fixture.detectChanges(); }); it("should have empty innerHtml when input is not an Icon", () => { - const fakeIcon = { svg: "harmful user input" } as Icon; + const fakeIcon = { svg: "harmful user input" } as BitSvg; - fixture.componentRef.setInput("icon", fakeIcon); + fixture.componentRef.setInput("content", fakeIcon); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; @@ -27,9 +27,9 @@ describe("IconComponent", () => { }); it("should contain icon when input is a safe Icon", () => { - const icon = svgIcon`safe icon`; + const icon = svg`safe icon`; - fixture.componentRef.setInput("icon", icon); + fixture.componentRef.setInput("content", icon); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; diff --git a/libs/components/src/svg/svg.mdx b/libs/components/src/svg/svg.mdx new file mode 100644 index 00000000000..a29a6f86b14 --- /dev/null +++ b/libs/components/src/svg/svg.mdx @@ -0,0 +1,120 @@ +import { Meta, Story, Controls } from "@storybook/addon-docs/blocks"; + +import * as stories from "./svg.stories"; + + + +```ts +import { SvgModule } from "@bitwarden/components"; +``` + +# Svg Use Instructions + +- Icons will generally be attached to the associated Jira task. + - Designers should minify any SVGs before attaching them to Jira using a tool like + [SVGOMG](https://jakearchibald.github.io/svgomg/). + - **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon + is desired. + +## Developer Instructions + +1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice. + - The SVG should be formatted using either a built-in formatter or an external tool like + [SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying + classes easier. + +2. **Rename the file** as a `.icon.ts` TypeScript file and place it in the `libs/assets/svg` + lib. + +3. **Import** `svg` from `./svg`. + +4. **Define and export** a `const` to represent your `svg`. + + ```typescript + export const ExampleIcon = svg``; + ``` + +5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class. + - **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when + styling the inside of an SVG path. + + - A non-comprehensive list of common colors and their associated classes is below: + + | Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable | + | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------- | + | `#020F66` | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | + | `#DBE5F6` | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | + | `#AAC3EF` | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | + | `#FFFFFF` | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | + | `#FFBF00` | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | + | `#175DDC` | `tw-stroke-illustration-logo` | `tw-fill-illustration-logo` | `--color-illustration-logo` | + + - If the hex that you have on an SVG path is not listed above, there are a few ways to figure out + the appropriate Tailwind class: + - **Option 1: Figma** + - Open the SVG in Figma. + - Click on an individual path on the SVG until you see the path's properties in the + right-hand panel. + - Scroll down to the Colors section. + - Example: `Color/Illustration/Outline` + - This also includes Hex or RGB values that can be used to find the appropriate Tailwind + variable as well if you follow the manual search option below. + - Create the appropriate stroke or fill class from the color used. + - Example: `Color/Illustration/Outline` corresponds to `--color-illustration-outline` which + corresponds to `tw-stroke-illustration-outline` or `tw-fill-illustration-outline`. + - **Option 2: Manual Search** + - Take the path's stroke or fill hex value and convert it to RGB using a tool like + [Hex to RGB](https://www.rgbtohex.net/hex-to-rgb/). + - Search for the RGB value without commas in our `tw-theme.css` to find the Tailwind variable + that corresponds to the color. + - Create the appropriate stroke or fill class using the Tailwind variable. + - Example: `--color-illustration-outline` corresponds to `tw-stroke-illustration-outline` + or `tw-fill-illustration-outline`. + +6. **Remove any hardcoded width or height attributes** if your SVG has a configured + [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order + to allow the SVG to scale to fit its container. + - **Note:** Scaling is required for any SVG used as an + [AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`. + +7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the + referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`). + +8. **Import your SVG const** anywhere you want to use the SVG. + - **Angular Component Example:** + - **TypeScript:** + + ```typescript + import { Component } from "@angular/core"; + import { SvgModule } from '@bitwarden/components'; + import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg"; + + @Component({ + selector: "app-example", + standalone: true, + imports: [SvgModule], + templateUrl: "./example.component.html", + }) + export class ExampleComponent { + readonly Icons = { ExampleIcon, Example2Icon }; + ... + } + ``` + + - **HTML:** + + > NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an + > `ariaLabel` is explicitly provided to the `` component + + ```html + + ``` + + With `ariaLabel` + + ```html + + ``` + +9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client + which supports multiple style modes. diff --git a/libs/components/src/svg/svg.module.ts b/libs/components/src/svg/svg.module.ts new file mode 100644 index 00000000000..c1cdae0e232 --- /dev/null +++ b/libs/components/src/svg/svg.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from "@angular/core"; + +import { SvgComponent } from "./svg.component"; + +@NgModule({ + imports: [SvgComponent], + exports: [SvgComponent], +}) +export class SvgModule {} diff --git a/libs/components/src/svg/svg.stories.ts b/libs/components/src/svg/svg.stories.ts new file mode 100644 index 00000000000..b2eb10771ce --- /dev/null +++ b/libs/components/src/svg/svg.stories.ts @@ -0,0 +1,50 @@ +import { Meta } from "@storybook/angular"; + +import * as SvgIcons from "@bitwarden/assets/svg"; + +import { SvgComponent } from "./svg.component"; + +export default { + title: "Component Library/Svg", + component: SvgComponent, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11", + }, + }, +} as Meta; + +const { + // Filtering out the few non-icons in the libs/assets/svg import + // eslint-disable-next-line @typescript-eslint/no-unused-vars + DynamicContentNotAllowedError: _DynamicContentNotAllowedError, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isBitSvg, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + svg, + ...Icons +}: { + [key: string]: any; +} = SvgIcons; + +export const Default = { + render: (args: { icons: [string, any][] }) => ({ + props: args, + template: /*html*/ ` +
    + @for (icon of icons; track icon[0]) { +
    +
    {{icon[0]}}
    +
    + +
    +
    + } +
    + `, + }), + args: { + icons: Object.entries(Icons), + }, +}; diff --git a/libs/eslint/components/index.mjs b/libs/eslint/components/index.mjs index 273c29890fe..101fdde414c 100644 --- a/libs/eslint/components/index.mjs +++ b/libs/eslint/components/index.mjs @@ -1,9 +1,11 @@ import requireLabelOnBiticonbutton from "./require-label-on-biticonbutton.mjs"; import requireThemeColorsInSvg from "./require-theme-colors-in-svg.mjs"; +import noBwiClassUsage from "./no-bwi-class-usage.mjs"; export default { rules: { "require-label-on-biticonbutton": requireLabelOnBiticonbutton, "require-theme-colors-in-svg": requireThemeColorsInSvg, + "no-bwi-class-usage": noBwiClassUsage, }, }; diff --git a/libs/eslint/components/no-bwi-class-usage.mjs b/libs/eslint/components/no-bwi-class-usage.mjs new file mode 100644 index 00000000000..8260587ce45 --- /dev/null +++ b/libs/eslint/components/no-bwi-class-usage.mjs @@ -0,0 +1,45 @@ +export const errorMessage = + "Use component instead of applying 'bwi' classes directly. Example: "; + +export default { + meta: { + type: "suggestion", + docs: { + description: + "Discourage using 'bwi' font icon classes directly in favor of the component", + category: "Best Practices", + recommended: true, + }, + schema: [], + }, + create(context) { + return { + Element(node) { + // Get all class-related attributes + const classAttrs = [ + ...(node.attributes?.filter((attr) => attr.name === "class") ?? []), + ...(node.inputs?.filter((input) => input.name === "class") ?? []), + ...(node.templateAttrs?.filter((attr) => attr.name === "class") ?? []), + ]; + + for (const classAttr of classAttrs) { + const classValue = classAttr.value || ""; + + // Check if the class value contains 'bwi' or 'bwi-' + // This handles both string literals and template expressions + const hasBwiClass = + typeof classValue === "string" && /\bbwi(?:-[\w-]+)?\b/.test(classValue); + + if (hasBwiClass) { + context.report({ + node, + message: errorMessage, + }); + // Only report once per element + break; + } + } + }, + }; + }, +}; diff --git a/libs/eslint/components/no-bwi-class-usage.spec.mjs b/libs/eslint/components/no-bwi-class-usage.spec.mjs new file mode 100644 index 00000000000..abb5ebe3b29 --- /dev/null +++ b/libs/eslint/components/no-bwi-class-usage.spec.mjs @@ -0,0 +1,44 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; + +import rule, { errorMessage } from "./no-bwi-class-usage.mjs"; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: require("@angular-eslint/template-parser"), + }, +}); + +ruleTester.run("no-bwi-class-usage", rule.default, { + valid: [ + { + name: "should allow bit-icon component usage", + code: ``, + }, + { + name: "should allow elements without bwi classes", + code: `
    `, + }, + ], + invalid: [ + { + name: "should error on direct bwi class usage", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on bwi class with other classes", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on single bwi-* class", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on bwi-fw modifier", + code: ``, + errors: [{ message: errorMessage }], + }, + ], +}); diff --git a/libs/eslint/components/require-theme-colors-in-svg.mjs b/libs/eslint/components/require-theme-colors-in-svg.mjs index fcc9cba461c..d30840710ca 100644 --- a/libs/eslint/components/require-theme-colors-in-svg.mjs +++ b/libs/eslint/components/require-theme-colors-in-svg.mjs @@ -25,7 +25,7 @@ export default { tagNames: { type: "array", items: { type: "string" }, - default: ["svgIcon"], + default: ["svg"], }, }, additionalProperties: false, @@ -35,7 +35,7 @@ export default { create(context) { const options = context.options[0] || {}; - const tagNames = options.tagNames || ["svgIcon"]; + const tagNames = options.tagNames || ["svg"]; function isSvgTaggedTemplate(node) { return ( diff --git a/libs/eslint/components/require-theme-colors-in-svg.spec.mjs b/libs/eslint/components/require-theme-colors-in-svg.spec.mjs index fd513ba57b3..f51871fdc9a 100644 --- a/libs/eslint/components/require-theme-colors-in-svg.spec.mjs +++ b/libs/eslint/components/require-theme-colors-in-svg.spec.mjs @@ -17,36 +17,36 @@ ruleTester.run("require-theme-colors-in-svg", rule.default, { valid: [ { name: "Allows fill=none", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, { name: "Allows CSS variable", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, { name: "Allows class-based coloring", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, ], invalid: [ { name: "Errors on fill with hex color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "#000000" } }], }, { name: "Errors on stroke with named color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "red" } }], }, { name: "Errors on fill with rgb()", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "rgb(255,0,0)" } }], }, { name: "Errors on fill with named color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "blue" } }], }, ], diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts index 735d694152c..669b54c5b57 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { BadgeVariant, ButtonType, IconModule, TypographyModule } from "@bitwarden/components"; +import { BadgeVariant, ButtonType, SvgModule, TypographyModule } from "@bitwarden/components"; import { PricingCardComponent } from "@bitwarden/pricing"; @Component({ @@ -68,13 +68,7 @@ describe("PricingCardComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - PricingCardComponent, - TestHostComponent, - IconModule, - TypographyModule, - CommonModule, - ], + imports: [PricingCardComponent, TestHostComponent, SvgModule, TypographyModule, CommonModule], }).compileComponents(); // For signal inputs, we need to set required inputs through the host component diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.ts index c9da7c32462..4b9241fc9dd 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.ts @@ -7,7 +7,7 @@ import { ButtonModule, ButtonType, CardComponent, - IconModule, + SvgModule, TypographyModule, } from "@bitwarden/components"; @@ -20,7 +20,7 @@ import { selector: "billing-pricing-card", templateUrl: "./pricing-card.component.html", changeDetection: ChangeDetectionStrategy.OnPush, - imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe, CardComponent], + imports: [BadgeModule, ButtonModule, SvgModule, TypographyModule, CurrencyPipe, CardComponent], }) export class PricingCardComponent { readonly tagline = input.required(); diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html index 7af120cfd6c..913d1b7963b 100644 --- a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html @@ -9,5 +9,5 @@ [attr.aria-label]="slide.label" (click)="onClick.emit()" > - + diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts index bef7f5b12d6..42fe082d5f8 100644 --- a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from "@angular/core"; import { CarouselIcon } from "@bitwarden/assets/svg"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.component"; @@ -12,7 +12,7 @@ import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.co @Component({ selector: "vault-carousel-button", templateUrl: "carousel-button.component.html", - imports: [CommonModule, IconModule], + imports: [CommonModule, SvgModule], }) export class VaultCarouselButtonComponent implements FocusableOption { /** Slide component that is associated with the individual button */ From 136705ac081684c993d3d32ad8da69920b7b72fe Mon Sep 17 00:00:00 2001 From: Jared Date: Wed, 28 Jan 2026 12:27:16 -0500 Subject: [PATCH 09/13] Refactor autofill policy naming and update related translations (#18628) - Renamed `activateAutofill` to `activateAutofillPolicy` in the policy order map and component. - Updated corresponding translation keys in `messages.json` for consistency. - Adjusted warning message in the `activate-autofill.component.html` to reflect the new naming convention. --- .../policies/pipes/policy-order.pipe.ts | 2 +- apps/web/src/locales/en/messages.json | 12 ++++++------ .../activate-autofill.component.html | 4 ++-- .../activate-autofill.component.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts index ec9fef23b9d..02092f05b92 100644 --- a/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts +++ b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts @@ -20,7 +20,7 @@ const POLICY_ORDER_MAP = new Map([ ["removeUnlockWithPinPolicyTitle", 10], ["passwordGenerator", 11], ["uriMatchDetectionPolicy", 12], - ["activateAutofill", 13], + ["activateAutofillPolicy", 13], ["sendOptions", 14], ["disableSend", 15], ["restrictedItemTypePolicy", 16], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3ba1ffc910b..ecb5f8d2dfc 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6937,17 +6937,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, "activateAutofillPolicyDescription": { "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.html b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.html index e2dbc8e8326..32ac2e229a9 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.html @@ -1,11 +1,11 @@ - {{ "experimentalFeature" | i18n }} + {{ "autofillOnPageLoadExploitWarning" | i18n }} {{ "learnMoreAboutAutofill" | i18n }}{{ "learnMoreAboutAutofillPolicy" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts index 03eb189741c..984a3dc1aff 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts @@ -11,7 +11,7 @@ import { import { SharedModule } from "@bitwarden/web-vault/app/shared"; export class ActivateAutofillPolicy extends BasePolicyEditDefinition { - name = "activateAutofill"; + name = "activateAutofillPolicy"; description = "activateAutofillPolicyDescription"; type = PolicyType.ActivateAutofill; component = ActivateAutofillPolicyComponent; From c07beb3b10894bd841c542bfe4e11d72a10ffac1 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 28 Jan 2026 09:38:15 -0800 Subject: [PATCH 10/13] [PM-31282] Pass orgId through to API call when SDK feature flag is off (#18619) --- libs/common/src/vault/services/cipher.service.spec.ts | 2 +- libs/common/src/vault/services/cipher.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 07444d5d1c6..28b1f064d89 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1209,7 +1209,7 @@ describe("Cipher Service", () => { await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); - expect(apiSpy).toHaveBeenCalled(); + expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId }); }); it("should use SDK to soft delete multiple ciphers when feature flag is enabled", async () => { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 1fc455a1ae9..81060870e8b 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1619,7 +1619,7 @@ export class CipherService implements CipherServiceAbstraction { return; } - const request = new CipherBulkDeleteRequest(ids); + const request = new CipherBulkDeleteRequest(ids, orgId); if (asAdmin) { await this.apiService.putDeleteManyCiphersAdmin(request); } else { From bddd6f5fb1e23211583e0e0f0c07508a8493e430 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 28 Jan 2026 13:31:49 -0500 Subject: [PATCH 11/13] [PM-31253] Desktop Footer Tooltip Updates (#18580) * update desktop archive and delete btns so tooltip shows on hover consistently. --- .../app/vault/item-footer.component.html | 109 +++++++++--------- 1 file changed, 57 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.html b/apps/desktop/src/vault/app/vault/item-footer.component.html index 0af73bf7d8a..5e3de1e6a14 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.html +++ b/apps/desktop/src/vault/app/vault/item-footer.component.html @@ -11,61 +11,66 @@ > {{ submitButtonText() }} - - - + @if (!cipher.isDeleted && action === "view") { + + } + + @if (action === "edit" || action === "clone" || action === "add") { + + } + + @if (cipher.isDeleted && cipher.permissions.restore) { + + } + @if (showCloneOption) { } -
    - - - -
    + @if (hasFooterAction) { +
    + @if (showArchiveButton) { + + } + + @if (showUnarchiveButton) { + + } + + +
    + }

    From 3632afd26e56d06493ecd70eb8773f9c294b2fda Mon Sep 17 00:00:00 2001 From: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:48:58 +0100 Subject: [PATCH 12/13] Remove `ts-strict-ignore` from fido2 page (#18146) * Remove `ts-strict-ignore` from fido2 page * Update typing issue * Fix AssertCredentialResult type issue * Remove non null assertions and add type guard * Addresses topWindow non null assertion * remove redundant check and remove ts strict from messenger --------- Co-authored-by: Jonathan Prusik Co-authored-by: Daniel Riera --- .../fido2/content/fido2-page-script.ts | 49 +++++++++++++------ .../fido2/content/messaging/messenger.ts | 6 +-- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts index 1cd614a9516..d55e0827352 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts @@ -1,12 +1,10 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { WebauthnUtils } from "../utils/webauthn-utils"; import { MessageTypes } from "./messaging/message"; import { Messenger } from "./messaging/messenger"; (function (globalContext) { - if (globalContext.document.currentScript) { + if (globalContext.document.currentScript?.parentNode) { globalContext.document.currentScript.parentNode.removeChild( globalContext.document.currentScript, ); @@ -86,7 +84,7 @@ import { Messenger } from "./messaging/messenger"; */ async function createWebAuthnCredential( options?: CredentialCreationOptions, - ): Promise { + ): Promise { if (!isWebauthnCall(options)) { return await browserCredentials.create(options); } @@ -106,13 +104,18 @@ import { Messenger } from "./messaging/messenger"; options?.signal, ); - if (response.type !== MessageTypes.CredentialCreationResponse) { + if (response.type !== MessageTypes.CredentialCreationResponse || !response.result) { throw new Error("Something went wrong."); } return WebauthnUtils.mapCredentialRegistrationResult(response.result); } catch (error) { - if (error && error.fallbackRequested && fallbackSupported) { + if ( + fallbackSupported && + error instanceof Object && + "fallbackRequested" in error && + error.fallbackRequested + ) { await waitForFocus(); return await browserCredentials.create(options); } @@ -127,7 +130,9 @@ import { Messenger } from "./messaging/messenger"; * @param options Options for creating new credentials. * @returns Promise that resolves to the new credential object. */ - async function getWebAuthnCredential(options?: CredentialRequestOptions): Promise { + async function getWebAuthnCredential( + options?: CredentialRequestOptions, + ): Promise { if (!isWebauthnCall(options)) { return await browserCredentials.get(options); } @@ -153,7 +158,7 @@ import { Messenger } from "./messaging/messenger"; internalAbortController.signal, ); internalAbortController.signal.removeEventListener("abort", abortListener); - if (response.type !== MessageTypes.CredentialGetResponse) { + if (response.type !== MessageTypes.CredentialGetResponse || !response.result) { throw new Error("Something went wrong."); } @@ -176,7 +181,7 @@ import { Messenger } from "./messaging/messenger"; abortSignal.removeEventListener("abort", abortListener); internalAbortControllers.forEach((controller) => controller.abort()); - return response; + return response ?? null; } try { @@ -188,13 +193,18 @@ import { Messenger } from "./messaging/messenger"; options?.signal, ); - if (response.type !== MessageTypes.CredentialGetResponse) { + if (response.type !== MessageTypes.CredentialGetResponse || !response.result) { throw new Error("Something went wrong."); } return WebauthnUtils.mapCredentialAssertResult(response.result); } catch (error) { - if (error && error.fallbackRequested && fallbackSupported) { + if ( + fallbackSupported && + error instanceof Object && + "fallbackRequested" in error && + error.fallbackRequested + ) { await waitForFocus(); return await browserCredentials.get(options); } @@ -203,8 +213,10 @@ import { Messenger } from "./messaging/messenger"; } } - function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) { - return options && "publicKey" in options; + function isWebauthnCall( + options?: CredentialCreationOptions | CredentialRequestOptions, + ): options is CredentialCreationOptions | CredentialRequestOptions { + return options != null && "publicKey" in options; } /** @@ -217,7 +229,7 @@ import { Messenger } from "./messaging/messenger"; */ async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) { try { - if (globalContext.top.document.hasFocus()) { + if (globalContext.top?.document.hasFocus()) { return; } } catch { @@ -225,9 +237,14 @@ import { Messenger } from "./messaging/messenger"; return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait)); } + if (!globalContext.top) { + return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait)); + } + + const topWindow = globalContext.top; const focusPromise = new Promise((resolve) => { focusListenerHandler = () => resolve(); - globalContext.top.addEventListener("focus", focusListenerHandler); + topWindow.addEventListener("focus", focusListenerHandler); }); const timeoutPromise = new Promise((_, reject) => { @@ -248,7 +265,7 @@ import { Messenger } from "./messaging/messenger"; } function clearWaitForFocus() { - globalContext.top.removeEventListener("focus", focusListenerHandler); + globalContext.top?.removeEventListener("focus", focusListenerHandler); if (waitForFocusTimeout) { globalContext.clearTimeout(waitForFocusTimeout); } diff --git a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts index 257f7e9efd5..78bb9aa8f33 100644 --- a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts +++ b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Message, MessageTypes } from "./message"; const SENDER = "bitwarden-webauthn"; @@ -25,7 +23,9 @@ type Handler = ( * handling aborts and exceptions across separate execution contexts. */ export class Messenger { - private messageEventListener: (event: MessageEvent) => void | null = null; + private messageEventListener: + | ((event: MessageEvent) => void | Promise) + | null = null; private onDestroy = new EventTarget(); /** From c5bd811dfd4267665fd84f9a6cbdd926517dcc17 Mon Sep 17 00:00:00 2001 From: Alex Dragovich <46065570+itsadrago@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:49:20 -0800 Subject: [PATCH 13/13] [PM-31323] change text on toast for send link copy (#18617) --- .../components/send-details/send-details.component.html | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index 6d42cca2186..581ee20caf7 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -30,6 +30,7 @@ showToast bitIconButton="bwi-clone" [appCopyClick]="sendLink" + [valueLabel]="'sendLink' | i18n" [label]="'copySendLink' | i18n" >