From e596584e87109a1e96c311d550e06ff48ee88562 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Thu, 1 May 2025 08:03:32 -0500 Subject: [PATCH 01/16] [PM-20505] Weak-passwords-report: refresh rows after edit (#14401) --- .../reports/pages/cipher-report.component.ts | 8 +- .../pages/weak-passwords-report.component.ts | 146 ++++++++++++------ ...dmin-console-cipher-form-config.service.ts | 2 +- 3 files changed, 107 insertions(+), 49 deletions(-) diff --git a/apps/web/src/app/tools/reports/pages/cipher-report.component.ts b/apps/web/src/app/tools/reports/pages/cipher-report.component.ts index 0ad8a0a519c..ceda7b1c480 100644 --- a/apps/web/src/app/tools/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/cipher-report.component.ts @@ -67,7 +67,7 @@ export class CipherReportComponent implements OnDestroy { protected i18nService: I18nService, private syncService: SyncService, private cipherFormConfigService: CipherFormConfigService, - private adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, + protected adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, ) { this.organizations$ = this.accountService.activeAccount$.pipe( getUserId, @@ -207,7 +207,7 @@ export class CipherReportComponent implements OnDestroy { // If the dialog was closed by deleting the cipher, refresh the report. if (result === VaultItemDialogResult.Deleted || result === VaultItemDialogResult.Saved) { - await this.load(); + await this.refresh(result, cipher); } } @@ -215,6 +215,10 @@ export class CipherReportComponent implements OnDestroy { this.allCiphers = []; } + protected async refresh(result: VaultItemDialogResult, cipher: CipherView) { + await this.load(); + } + protected async repromptCipher(c: CipherView) { return ( c.reprompt === CipherRepromptType.None || diff --git a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts index f7631e37a7d..4144c9ac20f 100644 --- a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts @@ -1,18 +1,22 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { CipherId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; +import { VaultItemDialogResult } from "@bitwarden/web-vault/app/vault/components/vault-item-dialog/vault-item-dialog.component"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; @@ -40,7 +44,7 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen i18nService: I18nService, syncService: SyncService, cipherFormConfigService: CipherFormConfigService, - adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, + protected adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, ) { super( cipherService, @@ -66,62 +70,112 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen this.findWeakPasswords(allCiphers); } - protected findWeakPasswords(ciphers: CipherView[]): void { - ciphers.forEach((ciph) => { - const { type, login, isDeleted, edit, viewPassword } = ciph; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { + protected async refresh(result: VaultItemDialogResult, cipher: CipherView) { + if (result === VaultItemDialogResult.Deleted) { + // remove the cipher from the list + this.weakPasswordCiphers = this.weakPasswordCiphers.filter((c) => c.id !== cipher.id); + this.filterCiphersByOrg(this.weakPasswordCiphers); + return; + } + + if (result == VaultItemDialogResult.Saved) { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + let updatedCipher = await this.cipherService.get(cipher.id, activeUserId); + + if (this.isAdminConsoleActive) { + updatedCipher = await this.adminConsoleCipherFormConfigService.getCipher( + cipher.id as CipherId, + this.organization, + ); + } + + const updatedCipherView = await updatedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId), + ); + // update the cipher views + const updatedReportResult = this.determineWeakPasswordScore(updatedCipherView); + const index = this.weakPasswordCiphers.findIndex((c) => c.id === updatedCipherView.id); + + if (updatedReportResult == null) { + // the password is no longer weak + // remove the cipher from the list + this.weakPasswordCiphers.splice(index, 1); + this.filterCiphersByOrg(this.weakPasswordCiphers); return; } - const hasUserName = this.isUserNameNotEmpty(ciph); - let userInput: string[] = []; - if (hasUserName) { - const atPosition = login.username.indexOf("@"); - if (atPosition > -1) { - userInput = userInput - .concat( - login.username - .substr(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/), - ) - .filter((i) => i.length >= 3); - } else { - userInput = login.username - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/) - .filter((i) => i.length >= 3); - } + if (index > -1) { + // update the existing cipher + this.weakPasswordCiphers[index] = updatedReportResult; + this.filterCiphersByOrg(this.weakPasswordCiphers); } - const result = this.passwordStrengthService.getPasswordStrength( - login.password, - null, - userInput.length > 0 ? userInput : null, - ); + } + } - if (result.score != null && result.score <= 2) { - const scoreValue = this.scoreKey(result.score); - const row = { - ...ciph, - score: result.score, - reportValue: scoreValue, - scoreKey: scoreValue.sortOrder, - } as ReportResult; + protected findWeakPasswords(ciphers: CipherView[]): void { + ciphers.forEach((ciph) => { + const row = this.determineWeakPasswordScore(ciph); + if (row != null) { this.weakPasswordCiphers.push(row); } }); this.filterCiphersByOrg(this.weakPasswordCiphers); } + protected determineWeakPasswordScore(ciph: CipherView): ReportResult | null { + const { type, login, isDeleted, edit, viewPassword } = ciph; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + (!this.organization && !edit) || + !viewPassword + ) { + return; + } + + const hasUserName = this.isUserNameNotEmpty(ciph); + let userInput: string[] = []; + if (hasUserName) { + const atPosition = login.username.indexOf("@"); + if (atPosition > -1) { + userInput = userInput + .concat( + login.username + .substr(0, atPosition) + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/), + ) + .filter((i) => i.length >= 3); + } else { + userInput = login.username + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/) + .filter((i) => i.length >= 3); + } + } + const result = this.passwordStrengthService.getPasswordStrength( + login.password, + null, + userInput.length > 0 ? userInput : null, + ); + + if (result.score != null && result.score <= 2) { + const scoreValue = this.scoreKey(result.score); + return { + ...ciph, + score: result.score, + reportValue: scoreValue, + scoreKey: scoreValue.sortOrder, + } as ReportResult; + } + + return null; + } + protected canManageCipher(c: CipherView): boolean { // this will only ever be false from the org view; return true; diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts index dd9cef91a54..15af27ba8d0 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -100,7 +100,7 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ }; } - private async getCipher(id: CipherId | null, organization: Organization): Promise { + async getCipher(id: CipherId | null, organization: Organization): Promise { if (id == null) { return null; } From abf7c949d98e9213cf37ae7339b9ab88031bb6ef Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 1 May 2025 15:22:18 +0200 Subject: [PATCH 02/16] Move additional linting to architecture (#14580) --- .github/renovate.json5 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ee97f16b0a9..91b4ac86328 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -149,6 +149,8 @@ { matchPackageNames: [ "@angular-eslint/schematics", + "@typescript-eslint/rule-tester", + "@typescript-eslint/utils", "angular-eslint", "eslint-config-prettier", "eslint-import-resolver-typescript", @@ -313,8 +315,6 @@ "@storybook/angular", "@storybook/manager-api", "@storybook/theming", - "@typescript-eslint/utils", - "@typescript-eslint/rule-tester", "@types/react", "autoprefixer", "bootstrap", From 1d004950785d9b003fd475a7e3e7f2c0f1e22640 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 1 May 2025 09:32:10 -0400 Subject: [PATCH 03/16] [PM-20032] Give option to skip token refresh on `fullSync` (#14423) * Give option to skip token refresh on fullSync * Fix listener --- .../sync/foreground-sync.service.spec.ts | 78 ++++++- .../platform/sync/foreground-sync.service.ts | 20 +- .../sync/sync-service.listener.spec.ts | 22 +- .../platform/sync/sync-service.listener.ts | 9 +- .../src/platform/sync/core-sync.service.ts | 3 + .../sync/default-sync.service.spec.ts | 199 ++++++++++++++++++ .../src/platform/sync/default-sync.service.ts | 15 +- libs/common/src/platform/sync/sync.service.ts | 29 ++- 8 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 libs/common/src/platform/sync/default-sync.service.spec.ts diff --git a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts index f5daff93815..34ee4fa0f77 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts @@ -8,6 +8,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; @@ -80,7 +81,72 @@ describe("ForegroundSyncService", () => { const fullSyncPromise = sut.fullSync(true, false); expect(sut.syncInProgress).toBe(true); - const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: false }); + const requestId = getAndAssertRequestId({ + forceSync: true, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + }); + + // Pretend the sync has finished + messages.next({ successfully: true, errorMessage: null, requestId: requestId }); + + const result = await fullSyncPromise; + + expect(sut.syncInProgress).toBe(false); + expect(result).toBe(true); + }); + + const testData: { + input: boolean | SyncOptions | undefined; + normalized: Required; + }[] = [ + { + input: undefined, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: true, + normalized: { allowThrowOnError: true, skipTokenRefresh: false }, + }, + { + input: false, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: false }, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: true }, + normalized: { allowThrowOnError: true, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: false, skipTokenRefresh: false }, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: true, skipTokenRefresh: false }, + normalized: { allowThrowOnError: true, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: true, skipTokenRefresh: true }, + normalized: { allowThrowOnError: true, skipTokenRefresh: true }, + }, + { + input: { allowThrowOnError: false, skipTokenRefresh: true }, + normalized: { allowThrowOnError: false, skipTokenRefresh: true }, + }, + ]; + + it.each(testData)("normalize input $input options correctly", async ({ input, normalized }) => { + const messages = new Subject(); + messageListener.messages$.mockReturnValue(messages); + const fullSyncPromise = sut.fullSync(true, input); + expect(sut.syncInProgress).toBe(true); + + const requestId = getAndAssertRequestId({ + forceSync: true, + options: normalized, + }); // Pretend the sync has finished messages.next({ successfully: true, errorMessage: null, requestId: requestId }); @@ -97,7 +163,10 @@ describe("ForegroundSyncService", () => { const fullSyncPromise = sut.fullSync(false, false); expect(sut.syncInProgress).toBe(true); - const requestId = getAndAssertRequestId({ forceSync: false, allowThrowOnError: false }); + const requestId = getAndAssertRequestId({ + forceSync: false, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + }); // Pretend the sync has finished messages.next({ @@ -118,7 +187,10 @@ describe("ForegroundSyncService", () => { const fullSyncPromise = sut.fullSync(true, true); expect(sut.syncInProgress).toBe(true); - const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: true }); + const requestId = getAndAssertRequestId({ + forceSync: true, + options: { allowThrowOnError: true, skipTokenRefresh: false }, + }); // Pretend the sync has finished messages.next({ diff --git a/apps/browser/src/platform/sync/foreground-sync.service.ts b/apps/browser/src/platform/sync/foreground-sync.service.ts index a6ed7281851..ce776f53685 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.ts @@ -14,6 +14,7 @@ import { import { Utils } from "@bitwarden/common/platform/misc/utils"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CoreSyncService } from "@bitwarden/common/platform/sync/internal"; +import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -22,7 +23,7 @@ import { InternalFolderService } from "@bitwarden/common/vault/abstractions/fold import { FULL_SYNC_FINISHED } from "./sync-service.listener"; -export type FullSyncMessage = { forceSync: boolean; allowThrowOnError: boolean; requestId: string }; +export type FullSyncMessage = { forceSync: boolean; options: SyncOptions; requestId: string }; export const DO_FULL_SYNC = new CommandDefinition("doFullSync"); @@ -60,9 +61,20 @@ export class ForegroundSyncService extends CoreSyncService { ); } - async fullSync(forceSync: boolean, allowThrowOnError: boolean = false): Promise { + async fullSync( + forceSync: boolean, + allowThrowOnErrorOrOptions?: boolean | SyncOptions, + ): Promise { this.syncInProgress = true; try { + // Normalize options + const options = + typeof allowThrowOnErrorOrOptions === "boolean" + ? { allowThrowOnError: allowThrowOnErrorOrOptions, skipTokenRefresh: false } + : { + allowThrowOnError: allowThrowOnErrorOrOptions?.allowThrowOnError ?? false, + skipTokenRefresh: allowThrowOnErrorOrOptions?.skipTokenRefresh ?? false, + }; const requestId = Utils.newGuid(); const syncCompletedPromise = firstValueFrom( this.messageListener.messages$(FULL_SYNC_FINISHED).pipe( @@ -79,10 +91,10 @@ export class ForegroundSyncService extends CoreSyncService { }), ), ); - this.messageSender.send(DO_FULL_SYNC, { forceSync, allowThrowOnError, requestId }); + this.messageSender.send(DO_FULL_SYNC, { forceSync, options, requestId }); const result = await syncCompletedPromise; - if (allowThrowOnError && result.errorMessage != null) { + if (options.allowThrowOnError && result.errorMessage != null) { throw new Error(result.errorMessage); } diff --git a/apps/browser/src/platform/sync/sync-service.listener.spec.ts b/apps/browser/src/platform/sync/sync-service.listener.spec.ts index 51f97e9f879..9682e2cdb57 100644 --- a/apps/browser/src/platform/sync/sync-service.listener.spec.ts +++ b/apps/browser/src/platform/sync/sync-service.listener.spec.ts @@ -27,11 +27,18 @@ describe("SyncServiceListener", () => { const emissionPromise = firstValueFrom(listener); syncService.fullSync.mockResolvedValueOnce(value); - messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" }); + messages.next({ + forceSync: true, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + requestId: "1", + }); await emissionPromise; - expect(syncService.fullSync).toHaveBeenCalledWith(true, false); + expect(syncService.fullSync).toHaveBeenCalledWith(true, { + allowThrowOnError: false, + skipTokenRefresh: false, + }); expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, { successfully: value, errorMessage: null, @@ -45,11 +52,18 @@ describe("SyncServiceListener", () => { const emissionPromise = firstValueFrom(listener); syncService.fullSync.mockRejectedValueOnce(new Error("SyncError")); - messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" }); + messages.next({ + forceSync: true, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + requestId: "1", + }); await emissionPromise; - expect(syncService.fullSync).toHaveBeenCalledWith(true, false); + expect(syncService.fullSync).toHaveBeenCalledWith(true, { + allowThrowOnError: false, + skipTokenRefresh: false, + }); expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, { successfully: false, errorMessage: "SyncError", diff --git a/apps/browser/src/platform/sync/sync-service.listener.ts b/apps/browser/src/platform/sync/sync-service.listener.ts index b7171528648..4274eafcf6a 100644 --- a/apps/browser/src/platform/sync/sync-service.listener.ts +++ b/apps/browser/src/platform/sync/sync-service.listener.ts @@ -9,6 +9,7 @@ import { MessageSender, isExternalMessage, } from "@bitwarden/common/platform/messaging"; +import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DO_FULL_SYNC } from "./foreground-sync.service"; @@ -34,15 +35,15 @@ export class SyncServiceListener { listener$(): Observable { return this.messageListener.messages$(DO_FULL_SYNC).pipe( filter((message) => isExternalMessage(message)), - concatMap(async ({ forceSync, allowThrowOnError, requestId }) => { - await this.doFullSync(forceSync, allowThrowOnError, requestId); + concatMap(async ({ forceSync, options, requestId }) => { + await this.doFullSync(forceSync, options, requestId); }), ); } - private async doFullSync(forceSync: boolean, allowThrowOnError: boolean, requestId: string) { + private async doFullSync(forceSync: boolean, options: SyncOptions, requestId: string) { try { - const result = await this.syncService.fullSync(forceSync, allowThrowOnError); + const result = await this.syncService.fullSync(forceSync, options); this.messageSender.send(FULL_SYNC_FINISHED, { successfully: result, errorMessage: null, diff --git a/libs/common/src/platform/sync/core-sync.service.ts b/libs/common/src/platform/sync/core-sync.service.ts index 1865ffb852f..4020c75f764 100644 --- a/libs/common/src/platform/sync/core-sync.service.ts +++ b/libs/common/src/platform/sync/core-sync.service.ts @@ -28,6 +28,8 @@ import { StateService } from "../abstractions/state.service"; import { MessageSender } from "../messaging"; import { StateProvider, SYNC_DISK, UserKeyDefinition } from "../state"; +import { SyncOptions } from "./sync.service"; + const LAST_SYNC_DATE = new UserKeyDefinition(SYNC_DISK, "lastSync", { deserializer: (d) => (d != null ? new Date(d) : null), clearOn: ["logout"], @@ -55,6 +57,7 @@ export abstract class CoreSyncService implements SyncService { protected readonly stateProvider: StateProvider, ) {} + abstract fullSync(forceSync: boolean, syncOptions?: SyncOptions): Promise; abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise; async getLastSync(): Promise { diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts new file mode 100644 index 00000000000..ded06c8be6b --- /dev/null +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -0,0 +1,199 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { + LogoutReason, + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { KeyService } from "@bitwarden/key-management"; + +import { Matrix } from "../../../spec/matrix"; +import { ApiService } from "../../abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } from "../../admin-console/abstractions/organization/organization.service.abstraction"; +import { InternalPolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; +import { ProviderService } from "../../admin-console/abstractions/provider.service"; +import { Account, AccountService } from "../../auth/abstractions/account.service"; +import { AuthService } from "../../auth/abstractions/auth.service"; +import { AvatarService } from "../../auth/abstractions/avatar.service"; +import { TokenService } from "../../auth/abstractions/token.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "../../billing/abstractions"; +import { KeyConnectorService } from "../../key-management/key-connector/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "../../key-management/master-password/abstractions/master-password.service.abstraction"; +import { SendApiService } from "../../tools/send/services/send-api.service.abstraction"; +import { InternalSendService } from "../../tools/send/services/send.service.abstraction"; +import { UserId } from "../../types/guid"; +import { CipherService } from "../../vault/abstractions/cipher.service"; +import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction"; +import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; +import { LogService } from "../abstractions/log.service"; +import { StateService } from "../abstractions/state.service"; +import { MessageSender } from "../messaging"; +import { StateProvider } from "../state"; + +import { DefaultSyncService } from "./default-sync.service"; +import { SyncResponse } from "./sync.response"; + +describe("DefaultSyncService", () => { + let masterPasswordAbstraction: MockProxy; + let accountService: MockProxy; + let apiService: MockProxy; + let domainSettingsService: MockProxy; + let folderService: MockProxy; + let cipherService: MockProxy; + let keyService: MockProxy; + let collectionService: MockProxy; + let messageSender: MockProxy; + let policyService: MockProxy; + let sendService: MockProxy; + let logService: MockProxy; + let keyConnectorService: MockProxy; + let stateService: MockProxy; + let providerService: MockProxy; + let folderApiService: MockProxy; + let organizationService: MockProxy; + let sendApiService: MockProxy; + let userDecryptionOptionsService: MockProxy; + let avatarService: MockProxy; + let logoutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: UserId]>; + let billingAccountProfileStateService: MockProxy; + let tokenService: MockProxy; + let authService: MockProxy; + let stateProvider: MockProxy; + + let sut: DefaultSyncService; + + beforeEach(() => { + masterPasswordAbstraction = mock(); + accountService = mock(); + apiService = mock(); + domainSettingsService = mock(); + folderService = mock(); + cipherService = mock(); + keyService = mock(); + collectionService = mock(); + messageSender = mock(); + policyService = mock(); + sendService = mock(); + logService = mock(); + keyConnectorService = mock(); + stateService = mock(); + providerService = mock(); + folderApiService = mock(); + organizationService = mock(); + sendApiService = mock(); + userDecryptionOptionsService = mock(); + avatarService = mock(); + logoutCallback = jest.fn(); + billingAccountProfileStateService = mock(); + tokenService = mock(); + authService = mock(); + stateProvider = mock(); + + sut = new DefaultSyncService( + masterPasswordAbstraction, + accountService, + apiService, + domainSettingsService, + folderService, + cipherService, + keyService, + collectionService, + messageSender, + policyService, + sendService, + logService, + keyConnectorService, + stateService, + providerService, + folderApiService, + organizationService, + sendApiService, + userDecryptionOptionsService, + avatarService, + logoutCallback, + billingAccountProfileStateService, + tokenService, + authService, + stateProvider, + ); + }); + + const user1 = "user1" as UserId; + + describe("fullSync", () => { + beforeEach(() => { + accountService.activeAccount$ = of({ id: user1 } as Account); + Matrix.autoMockMethod(authService.authStatusFor$, () => of(AuthenticationStatus.Unlocked)); + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: { + id: user1, + }, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + Matrix.autoMockMethod(userDecryptionOptionsService.userDecryptionOptionsById$, () => + of({ hasMasterPassword: true } satisfies UserDecryptionOptions), + ); + stateProvider.getUser.mockReturnValue(mock()); + }); + + it("does a token refresh when option missing from options", async () => { + await sut.fullSync(true, { allowThrowOnError: false }); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when boolean passed in", async () => { + await sut.fullSync(true, false); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when skipTokenRefresh option passed in with false and allowThrowOnError also passed in", async () => { + await sut.fullSync(true, { allowThrowOnError: false, skipTokenRefresh: false }); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when skipTokenRefresh option passed in with false by itself", async () => { + await sut.fullSync(true, { skipTokenRefresh: false }); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does not do a token refresh when skipTokenRefresh passed in as true", async () => { + await sut.fullSync(true, { skipTokenRefresh: true }); + + expect(apiService.refreshIdentityToken).not.toHaveBeenCalled(); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does not do a token refresh when skipTokenRefresh passed in as true and allowThrowOnError also passed in", async () => { + await sut.fullSync(true, { allowThrowOnError: false, skipTokenRefresh: true }); + + expect(apiService.refreshIdentityToken).not.toHaveBeenCalled(); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when nothing passed in", async () => { + await sut.fullSync(true); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index a6b1b974645..faf54f11912 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -54,6 +54,7 @@ import { MessageSender } from "../messaging"; import { StateProvider } from "../state"; import { CoreSyncService } from "./core-sync.service"; +import { SyncOptions } from "./sync.service"; export class DefaultSyncService extends CoreSyncService { syncInProgress = false; @@ -102,7 +103,15 @@ export class DefaultSyncService extends CoreSyncService { ); } - override async fullSync(forceSync: boolean, allowThrowOnError = false): Promise { + override async fullSync( + forceSync: boolean, + allowThrowOnErrorOrOptions?: boolean | SyncOptions, + ): Promise { + const { allowThrowOnError = false, skipTokenRefresh = false } = + typeof allowThrowOnErrorOrOptions === "boolean" + ? { allowThrowOnError: allowThrowOnErrorOrOptions } + : (allowThrowOnErrorOrOptions ?? {}); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); this.syncStarted(); const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId)); @@ -127,7 +136,9 @@ export class DefaultSyncService extends CoreSyncService { } try { - await this.apiService.refreshIdentityToken(); + if (!skipTokenRefresh) { + await this.apiService.refreshIdentityToken(); + } const response = await this.apiService.getSync(); await this.syncProfile(response.profile); diff --git a/libs/common/src/platform/sync/sync.service.ts b/libs/common/src/platform/sync/sync.service.ts index 967e4db27a5..6ef62fc9cb8 100644 --- a/libs/common/src/platform/sync/sync.service.ts +++ b/libs/common/src/platform/sync/sync.service.ts @@ -7,6 +7,26 @@ import { } from "../../models/response/notification.response"; import { UserId } from "../../types/guid"; +/** + * A set of options for configuring how a {@link SyncService.fullSync} call should behave. + */ +export type SyncOptions = { + /** + * A boolean dictating whether or not caught errors should be rethrown. + * `true` if they can be rethrown, `false` if they should not be rethrown. + * @default false + */ + allowThrowOnError?: boolean; + /** + * A boolean dictating whether or not to do a token refresh before doing the sync. + * `true` if the refresh can be skipped, likely because one was done soon before the call to + * `fullSync`. `false` if the token refresh should be done before getting data. + * + * @default false + */ + skipTokenRefresh?: boolean; +}; + /** * A class encapsulating sync operations and data. */ @@ -47,9 +67,12 @@ export abstract class SyncService { * as long as the current user is authenticated. If `false` it will only sync if either a sync * has not happened before or the last sync date for the active user is before their account * revision date. Try to always use `false` if possible. - * - * @param allowThrowOnError A boolean dictating whether or not caught errors should be rethrown. - * `true` if they can be rethrown, `false` if they should not be rethrown. + * @param syncOptions Options for customizing how the sync call should behave. + */ + abstract fullSync(forceSync: boolean, syncOptions?: SyncOptions): Promise; + + /** + * @deprecated Use the overload taking {@link SyncOptions} instead. */ abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise; From 8090586b52006078250397916e67b9ad32526181 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Thu, 1 May 2025 07:18:09 -0700 Subject: [PATCH 04/16] Fix some references to master (#14578) * Fix some references to master * Fix broken links --- LICENSE.txt | 6 +++--- LICENSE_BITWARDEN.txt | 2 +- apps/browser/README.md | 4 ++-- apps/cli/README.md | 2 +- apps/desktop/README.md | 2 +- apps/web/README.md | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 55bf3b3f736..8ad59f788b3 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -5,13 +5,13 @@ specifies another license. Bitwarden Licensed code is found only in the /bitwarden_license directory. GPL v3.0: -https://github.com/bitwarden/web/blob/master/LICENSE_GPL.txt +https://github.com/bitwarden/clients/blob/main/LICENSE_GPL.txt Bitwarden License v1.0: -https://github.com/bitwarden/web/blob/master/LICENSE_BITWARDEN.txt +https://github.com/bitwarden/clients/blob/main/LICENSE_BITWARDEN.txt No grant of any rights in the trademarks, service marks, or logos of Bitwarden is made (except as may be necessary to comply with the notice requirements as applicable), and use of any Bitwarden trademarks must comply with Bitwarden Trademark Guidelines -. +. diff --git a/LICENSE_BITWARDEN.txt b/LICENSE_BITWARDEN.txt index 08e09f28639..938946a09a1 100644 --- a/LICENSE_BITWARDEN.txt +++ b/LICENSE_BITWARDEN.txt @@ -56,7 +56,7 @@ such Open Source Software only. logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 2.3), and use of any Bitwarden trademarks must comply with Bitwarden Trademark Guidelines -. +. 3. TERMINATION diff --git a/apps/browser/README.md b/apps/browser/README.md index c99d0844a09..fdeb1307567 100644 --- a/apps/browser/README.md +++ b/apps/browser/README.md @@ -1,4 +1,4 @@ -[![Github Workflow build browser on master](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:master) +[![Github Workflow build browser on main](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:main) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/bitwarden-browser/localized.svg)](https://crowdin.com/project/bitwarden-browser) [![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby) @@ -15,7 +15,7 @@ The Bitwarden browser extension is written using the Web Extension API and Angular. -![My Vault](https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/browser-chrome.png) +![My Vault](https://raw.githubusercontent.com/bitwarden/brand/main/screenshots/web-browser-extension-generator.png) ## Documentation diff --git a/apps/cli/README.md b/apps/cli/README.md index d39c0e39c8f..2b13270cdba 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,4 +1,4 @@ -[![Github Workflow build on master](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:master) +[![Github Workflow build on main](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:main) [![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby) # Bitwarden Command-line Interface diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6578699369b..ee13f451641 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,4 +1,4 @@ -[![Github Workflow build on master](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:master) +[![Github Workflow build on main](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:main) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/bitwarden-desktop/localized.svg)](https://crowdin.com/project/bitwarden-desktop) [![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby) diff --git a/apps/web/README.md b/apps/web/README.md index f43a9dc1614..c5e03eebb59 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,12 +1,12 @@

- +

The Bitwarden web project is an Angular application that powers the web vault (https://vault.bitwarden.com/).

- - Github Workflow build on master + + Github Workflow build on main Crowdin From 1b66f0f06b449444a0f81522c1fe8a28d998d88c Mon Sep 17 00:00:00 2001 From: Github Actions Date: Thu, 1 May 2025 14:22:26 +0000 Subject: [PATCH 05/16] Bumped Desktop client to 2025.5.0 --- apps/desktop/package.json | 2 +- apps/desktop/src/package-lock.json | 4 ++-- apps/desktop/src/package.json | 2 +- package-lock.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e00df0b26df..21892cd1df8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.4.2", + "version": "2025.5.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index f6449bd9626..b3a33dc75e3 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.4.2", + "version": "2025.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.4.2", + "version": "2025.5.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 45a6f6b90af..c180ed8c744 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.4.2", + "version": "2025.5.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/package-lock.json b/package-lock.json index 671951c0349..25322b844b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -231,7 +231,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.4.2", + "version": "2025.5.0", "hasInstallScript": true, "license": "GPL-3.0" }, From a7d04dc21276c5152ca6ab5d6f55159ec356c4c5 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 1 May 2025 16:36:00 +0100 Subject: [PATCH 06/16] [PM-17775] Allow admin to send f4 e sponsorship (#14390) * Added nav item for f4e in org admin console * shotgun surgery for adding "useAdminSponsoredFamilies" feature from the org table * Resolved issue with members nav item also being selected when f4e is selected * Separated out billing's logic from the org layout component * Removed unused observable * Moved logic to existing f4e policy service and added unit tests * Resolved script typescript error * Resolved goofy switchMap * Add changes for the issue orgs * Added changes for the dialog * Rename the files properly * Remove the commented code * Change the implement to align with design * Add todo comments * Remove the comment todo * Fix the uni test error * Resolve the unit test * Resolve the unit test issue * Resolve the pr comments on any and route * remove the any * remove the generic validator * Resolve the unit test * add validations for email * Add changes for the autoscale * Changes to allow admin to send F4E sponsorship * Fix the lint errors * Resolve the lint errors * Fix the revokeAccount message * Fix the lint runtime error * Resolve the lint issues * Remove unused components * Changes to add isadminInitiated * remove the FIXME comment * Resolve the failing test * Fix the pr comments * Resolve the orgkey and other comments * Resolve the lint error * Resolve the lint error * resolve the spelling error * refactor the getStatus method * Remove the deprecated method * Resolve the unusual type casting * revert the change --------- Co-authored-by: Conner Turnbull Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> --- .../add-sponsorship-dialog.component.html | 10 +- .../add-sponsorship-dialog.component.ts | 131 +++++---- .../free-bitwarden-families.component.html | 104 ++++++- .../free-bitwarden-families.component.ts | 261 +++++++++++++++--- ...rganization-member-families.component.html | 47 ---- .../organization-member-families.component.ts | 34 --- ...nization-sponsored-families.component.html | 87 ------ ...ganization-sponsored-families.component.ts | 39 --- .../sponsored-families.component.html | 2 +- .../sponsoring-org-row.component.html | 2 +- .../src/app/shared/loose-components.module.ts | 6 - apps/web/src/locales/en/messages.json | 21 +- .../src/services/jslib-services.module.ts | 7 + ...organization-sponsorship-create.request.ts | 1 + ...ion-sponsorship-api.service.abstraction.ts | 8 + ...ganization-sponsorship-invites.response.ts | 31 +++ .../organization-sponsorship-api.service.ts | 22 ++ 17 files changed, 491 insertions(+), 322 deletions(-) delete mode 100644 apps/web/src/app/billing/members/organization-member-families.component.html delete mode 100644 apps/web/src/app/billing/members/organization-member-families.component.ts delete mode 100644 apps/web/src/app/billing/members/organization-sponsored-families.component.html delete mode 100644 apps/web/src/app/billing/members/organization-sponsored-families.component.ts create mode 100644 libs/common/src/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction.ts create mode 100644 libs/common/src/billing/models/response/organization-sponsorship-invites.response.ts create mode 100644 libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts diff --git a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html index 2dbcc577e54..405211d6ecb 100644 --- a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html +++ b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html @@ -34,7 +34,15 @@ - - - - - + + +

+ {{ "sponsorshipFreeBitwardenFamilies" | i18n }} +

+
+ {{ "sponsoredFamiliesInclude" | i18n }}: +
    +
  • {{ "sponsoredFamiliesPremiumAccess" | i18n }}
  • +
  • {{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}
  • +
+
- - - - +

{{ "sponsoredBitwardenFamilies" | i18n }}

-

{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}

+ @if (loading()) { + + + {{ "loading" | i18n }} + + } + + @if (!loading() && sponsoredFamilies?.length > 0) { + + + + + {{ "recipient" | i18n }} + {{ "status" | i18n }} + {{ "notes" | i18n }} + + + + + @for (o of sponsoredFamilies; let i = $index; track i) { + + + {{ o.friendlyName }} + {{ o.statusMessage }} + {{ o.notes }} + + + + + +
+ + +
+ + +
+ } +
+
+
+
+ } @else if (!loading()) { +
+ Search +

{{ "noSponsoredFamiliesMessage" | i18n }}

+

{{ "nosponsoredFamiliesDetails" | i18n }}

+
+ } + + @if (!loading() && sponsoredFamilies.length > 0) { +

{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}

+ } + + diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts index af43e5a4bc1..c141eaebd78 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts @@ -1,62 +1,259 @@ import { DialogRef } from "@angular/cdk/dialog"; -import { Component, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { formatDate } from "@angular/common"; +import { Component, OnInit, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { firstValueFrom, map, Observable, switchMap } from "rxjs"; -import { DialogService } from "@bitwarden/components"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; +import { OrganizationSponsorshipInvitesResponse } from "@bitwarden/common/billing/models/response/organization-sponsorship-invites.response"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; -import { FreeFamiliesPolicyService } from "../services/free-families-policy.service"; - -import { - AddSponsorshipDialogComponent, - AddSponsorshipDialogResult, -} from "./add-sponsorship-dialog.component"; -import { SponsoredFamily } from "./types/sponsored-family"; +import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.component"; @Component({ selector: "app-free-bitwarden-families", templateUrl: "free-bitwarden-families.component.html", }) export class FreeBitwardenFamiliesComponent implements OnInit { + loading = signal(true); tabIndex = 0; - sponsoredFamilies: SponsoredFamily[] = []; + sponsoredFamilies: OrganizationSponsorshipInvitesResponse[] = []; + + organizationId = ""; + organizationKey$: Observable; + + private locale: string = ""; constructor( - private router: Router, + private route: ActivatedRoute, private dialogService: DialogService, - private freeFamiliesPolicyService: FreeFamiliesPolicyService, - ) {} + private apiService: ApiService, + private encryptService: EncryptService, + private keyService: KeyService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private logService: LogService, + private toastService: ToastService, + private organizationSponsorshipApiService: OrganizationSponsorshipApiServiceAbstraction, + private stateProvider: StateProvider, + ) { + this.organizationId = this.route.snapshot.params.organizationId || ""; + this.organizationKey$ = this.stateProvider.activeUserId$.pipe( + switchMap( + (userId) => + this.keyService.orgKeys$(userId as UserId) as Observable>, + ), + map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]), + takeUntilDestroyed(), + ); + } async ngOnInit() { - await this.preventAccessToFreeFamiliesPage(); + this.locale = await firstValueFrom(this.i18nService.locale$); + await this.loadSponsorships(); + + this.loading.set(false); + } + + async loadSponsorships() { + if (!this.organizationId) { + return; + } + + const [response, orgKey] = await Promise.all([ + this.organizationSponsorshipApiService.getOrganizationSponsorship(this.organizationId), + firstValueFrom(this.organizationKey$), + ]); + + if (!orgKey) { + this.logService.error("Organization key not found"); + return; + } + + const organizationFamilies = response.data; + + this.sponsoredFamilies = await Promise.all( + organizationFamilies.map(async (family) => { + let decryptedNote = ""; + try { + decryptedNote = await this.encryptService.decryptString( + new EncString(family.notes), + orgKey, + ); + } catch (e) { + this.logService.error(e); + } + + const { statusMessage, statusClass } = this.getStatus( + this.isSelfHosted, + family.toDelete, + family.validUntil, + family.lastSyncDate, + this.locale, + ); + + const newFamily = { + ...family, + notes: decryptedNote, + statusMessage: statusMessage || "", + statusClass: statusClass || "tw-text-success", + status: statusMessage || "", + }; + + return new OrganizationSponsorshipInvitesResponse(newFamily); + }), + ); } async addSponsorship() { - const addSponsorshipDialogRef: DialogRef = - AddSponsorshipDialogComponent.open(this.dialogService); + const addSponsorshipDialogRef: DialogRef = AddSponsorshipDialogComponent.open( + this.dialogService, + { + data: { + organizationId: this.organizationId, + organizationKey: await firstValueFrom(this.organizationKey$), + }, + }, + ); - const dialogRef = await firstValueFrom(addSponsorshipDialogRef.closed); + await firstValueFrom(addSponsorshipDialogRef.closed); - if (dialogRef?.value) { - this.sponsoredFamilies = [dialogRef.value, ...this.sponsoredFamilies]; + await this.loadSponsorships(); + } + + async removeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) { + try { + await this.doRevokeSponsorship(sponsorship); + } catch (e) { + this.logService.error(e); } } - removeSponsorhip(sponsorship: any) { - const index = this.sponsoredFamilies.findIndex( - (e) => e.sponsorshipEmail == sponsorship.sponsorshipEmail, - ); - this.sponsoredFamilies.splice(index, 1); + get isSelfHosted(): boolean { + return this.platformUtilsService.isSelfHost(); } - private async preventAccessToFreeFamiliesPage() { - const showFreeFamiliesPage = await firstValueFrom( - this.freeFamiliesPolicyService.showFreeFamilies$, - ); + async resendEmail(sponsorship: OrganizationSponsorshipInvitesResponse) { + await this.apiService.postResendSponsorshipOffer(sponsorship.sponsoringOrganizationUserId); + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("emailSent"), + }); + } - if (!showFreeFamiliesPage) { - await this.router.navigate(["/"]); + private async doRevokeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) { + const content = sponsorship.validUntil + ? this.i18nService.t( + "updatedRevokeSponsorshipConfirmationForAcceptedSponsorship", + sponsorship.friendlyName, + formatDate(sponsorship.validUntil, "MM/dd/yyyy", this.locale), + ) + : this.i18nService.t( + "updatedRevokeSponsorshipConfirmationForSentSponsorship", + sponsorship.friendlyName, + ); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: `${this.i18nService.t("removeSponsorship")}?`, + content, + acceptButtonText: { key: "remove" }, + type: "warning", + }); + + if (!confirmed) { return; } + + await this.apiService.deleteRevokeSponsorship(sponsorship.sponsoringOrganizationUserId); + + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("reclaimedFreePlan"), + }); + + await this.loadSponsorships(); + } + + private getStatus( + selfHosted: boolean, + toDelete?: boolean, + validUntil?: Date, + lastSyncDate?: Date, + locale: string = "", + ): { statusMessage: string; statusClass: "tw-text-success" | "tw-text-danger" } { + /* + * Possible Statuses: + * Requested (self-hosted only) + * Sent + * Active + * RequestRevoke + * RevokeWhenExpired + */ + + if (toDelete && validUntil) { + // They want to delete but there is a valid until date which means there is an active sponsorship + return { + statusMessage: this.i18nService.t( + "revokeWhenExpired", + formatDate(validUntil, "MM/dd/yyyy", locale), + ), + statusClass: "tw-text-danger", + }; + } + + if (toDelete) { + // They want to delete and we don't have a valid until date so we can + // this should only happen on a self-hosted install + return { + statusMessage: this.i18nService.t("requestRemoved"), + statusClass: "tw-text-danger", + }; + } + + if (validUntil) { + // They don't want to delete and they have a valid until date + // that means they are actively sponsoring someone + return { + statusMessage: this.i18nService.t("active"), + statusClass: "tw-text-success", + }; + } + + if (selfHosted && lastSyncDate) { + // We are on a self-hosted install and it has been synced but we have not gotten + // a valid until date so we can't know if they are actively sponsoring someone + return { + statusMessage: this.i18nService.t("sent"), + statusClass: "tw-text-success", + }; + } + + if (!selfHosted) { + // We are in cloud and all other status checks have been false therefore we have + // sent the request but it hasn't been accepted yet + return { + statusMessage: this.i18nService.t("sent"), + statusClass: "tw-text-success", + }; + } + + // We are on a self-hosted install and we have not synced yet + return { + statusMessage: this.i18nService.t("requested"), + statusClass: "tw-text-success", + }; } } diff --git a/apps/web/src/app/billing/members/organization-member-families.component.html b/apps/web/src/app/billing/members/organization-member-families.component.html deleted file mode 100644 index c5b7283d9d9..00000000000 --- a/apps/web/src/app/billing/members/organization-member-families.component.html +++ /dev/null @@ -1,47 +0,0 @@ - - -

- {{ "membersWithSponsoredFamilies" | i18n }} -

- -

{{ "memberFamilies" | i18n }}

- - @if (loading) { - - - {{ "loading" | i18n }} - - } - - @if (!loading && memberFamilies?.length > 0) { - - - - - {{ "member" | i18n }} - {{ "status" | i18n }} - - - - - @for (o of memberFamilies; let i = $index; track i) { - - - {{ o.sponsorshipEmail }} - {{ o.status }} - - - } - - -
-
- } @else { -
- Search -

{{ "noMemberFamilies" | i18n }}

-

{{ "noMemberFamiliesDescription" | i18n }}

-
- } -
-
diff --git a/apps/web/src/app/billing/members/organization-member-families.component.ts b/apps/web/src/app/billing/members/organization-member-families.component.ts deleted file mode 100644 index 52c95646a11..00000000000 --- a/apps/web/src/app/billing/members/organization-member-families.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Component, Input, OnDestroy, OnInit } from "@angular/core"; -import { Subject } from "rxjs"; - -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -import { SponsoredFamily } from "./types/sponsored-family"; - -@Component({ - selector: "app-organization-member-families", - templateUrl: "organization-member-families.component.html", -}) -export class OrganizationMemberFamiliesComponent implements OnInit, OnDestroy { - tabIndex = 0; - loading = false; - - @Input() memberFamilies: SponsoredFamily[] = []; - - private _destroy = new Subject(); - - constructor(private platformUtilsService: PlatformUtilsService) {} - - async ngOnInit() { - this.loading = false; - } - - ngOnDestroy(): void { - this._destroy.next(); - this._destroy.complete(); - } - - get isSelfHosted(): boolean { - return this.platformUtilsService.isSelfHost(); - } -} diff --git a/apps/web/src/app/billing/members/organization-sponsored-families.component.html b/apps/web/src/app/billing/members/organization-sponsored-families.component.html deleted file mode 100644 index 7db96deb4ab..00000000000 --- a/apps/web/src/app/billing/members/organization-sponsored-families.component.html +++ /dev/null @@ -1,87 +0,0 @@ - - -

- {{ "sponsorFreeBitwardenFamilies" | i18n }} -

-
- {{ "sponsoredFamiliesInclude" | i18n }}: -
    -
  • {{ "sponsoredFamiliesPremiumAccess" | i18n }}
  • -
  • {{ "sponsoredFamiliesSharedCollections" | i18n }}
  • -
-
- -

{{ "sponsoredBitwardenFamilies" | i18n }}

- - @if (loading) { - - - {{ "loading" | i18n }} - - } - - @if (!loading && sponsoredFamilies?.length > 0) { - - - - - {{ "recipient" | i18n }} - {{ "status" | i18n }} - {{ "notes" | i18n }} - - - - - @for (o of sponsoredFamilies; let i = $index; track i) { - - - {{ o.sponsorshipEmail }} - {{ o.status }} - {{ o.sponsorshipNote }} - - - - - -
- - -
- - -
- } -
-
-
-
- } @else { -
- Search -

{{ "noSponsoredFamilies" | i18n }}

-

{{ "noSponsoredFamiliesDescription" | i18n }}

-
- } -
-
diff --git a/apps/web/src/app/billing/members/organization-sponsored-families.component.ts b/apps/web/src/app/billing/members/organization-sponsored-families.component.ts deleted file mode 100644 index 7cc46634a38..00000000000 --- a/apps/web/src/app/billing/members/organization-sponsored-families.component.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { Subject } from "rxjs"; - -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -import { SponsoredFamily } from "./types/sponsored-family"; - -@Component({ - selector: "app-organization-sponsored-families", - templateUrl: "organization-sponsored-families.component.html", -}) -export class OrganizationSponsoredFamiliesComponent implements OnInit, OnDestroy { - loading = false; - tabIndex = 0; - - @Input() sponsoredFamilies: SponsoredFamily[] = []; - @Output() removeSponsorshipEvent = new EventEmitter(); - - private _destroy = new Subject(); - - constructor(private platformUtilsService: PlatformUtilsService) {} - - async ngOnInit() { - this.loading = false; - } - - get isSelfHosted(): boolean { - return this.platformUtilsService.isSelfHost(); - } - - remove(sponsorship: SponsoredFamily) { - this.removeSponsorshipEvent.emit(sponsorship); - } - - ngOnDestroy(): void { - this._destroy.next(); - this._destroy.complete(); - } -} diff --git a/apps/web/src/app/billing/settings/sponsored-families.component.html b/apps/web/src/app/billing/settings/sponsored-families.component.html index 12e942aaf18..8e829ae70ef 100644 --- a/apps/web/src/app/billing/settings/sponsored-families.component.html +++ b/apps/web/src/app/billing/settings/sponsored-families.component.html @@ -13,7 +13,7 @@ {{ "sponsoredFamiliesInclude" | i18n }}:
  • {{ "sponsoredFamiliesPremiumAccess" | i18n }}
  • -
  • {{ "sponsoredFamiliesSharedCollections" | i18n }}
  • +
  • {{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}
diff --git a/apps/web/src/app/billing/settings/sponsoring-org-row.component.html b/apps/web/src/app/billing/settings/sponsoring-org-row.component.html index eeeaa256049..1e5690cd85a 100644 --- a/apps/web/src/app/billing/settings/sponsoring-org-row.component.html +++ b/apps/web/src/app/billing/settings/sponsoring-org-row.component.html @@ -32,7 +32,7 @@ type="button" bitMenuItem (click)="revokeSponsorship()" - [attr.aria-label]="'revokeAccount' | i18n: sponsoringOrg.familySponsorshipFriendlyName" + [attr.aria-label]="'revokeAccountMessage' | i18n: sponsoringOrg.familySponsorshipFriendlyName" > {{ "remove" | i18n }} diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index bec06888c57..90e4c6ba9c3 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -62,8 +62,6 @@ import { PipesModule } from "../vault/individual-vault/pipes/pipes.module"; import { PurgeVaultComponent } from "../vault/settings/purge-vault.component"; import { FreeBitwardenFamiliesComponent } from "./../billing/members/free-bitwarden-families.component"; -import { OrganizationMemberFamiliesComponent } from "./../billing/members/organization-member-families.component"; -import { OrganizationSponsoredFamiliesComponent } from "./../billing/members/organization-sponsored-families.component"; import { EnvironmentSelectorModule } from "./../components/environment-selector/environment-selector.module"; import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component"; import { SharedModule } from "./shared.module"; @@ -128,8 +126,6 @@ import { SharedModule } from "./shared.module"; SelectableAvatarComponent, SetPasswordComponent, SponsoredFamiliesComponent, - OrganizationSponsoredFamiliesComponent, - OrganizationMemberFamiliesComponent, FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, UpdatePasswordComponent, @@ -176,8 +172,6 @@ import { SharedModule } from "./shared.module"; SelectableAvatarComponent, SetPasswordComponent, SponsoredFamiliesComponent, - OrganizationSponsoredFamiliesComponent, - OrganizationMemberFamiliesComponent, FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, UpdateTempPasswordComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4650ded54bb..c81954ef9ca 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6303,13 +6303,13 @@ "sponsoredBitwardenFamilies": { "message": "Sponsored families" }, - "noSponsoredFamilies": { + "noSponsoredFamiliesMessage": { "message": "No sponsored families" }, - "noSponsoredFamiliesDescription": { + "nosponsoredFamiliesDetails": { "message": "Sponsored non-member families plans will display here" }, - "sponsorFreeBitwardenFamilies": { + "sponsorshipFreeBitwardenFamilies": { "message": "Members of your organization are eligible for Free Bitwarden Families. You can sponsor Free Bitwarden Families for employees who are not a member of your Bitwarden organization. Sponsoring a non-member requires an available seat within your organization." }, "sponsoredFamiliesRemoveActiveSponsorship": { @@ -6327,8 +6327,8 @@ "sponsoredFamiliesPremiumAccess": { "message": "Premium access for up to 6 users" }, - "sponsoredFamiliesSharedCollections": { - "message": "Shared collections for Family secrets" + "sponsoredFamiliesSharedCollectionsMessage": { + "message": "Shared collections for family members" }, "memberFamilies": { "message": "Member families" @@ -6342,6 +6342,15 @@ "membersWithSponsoredFamilies": { "message": "Members of your organization are eligible for Free Bitwarden Families. Here you can see members who have sponsored a Families organization." }, + "organizationHasMemberMessage": { + "message": "A sponsorship cannot be sent to $EMAIL$ because they are a member of your organization.", + "placeholders": { + "email": { + "content": "$1", + "example": "mail@example.com" + } + } + }, "badToken": { "message": "The link is no longer valid. Please have the sponsor resend the offer." }, @@ -6393,7 +6402,7 @@ "redeemedAccount": { "message": "Account redeemed" }, - "revokeAccount": { + "revokeAccountMessage": { "message": "Revoke account $NAME$", "placeholders": { "name": { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 1cc2b591412..0d59f4a6547 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -136,11 +136,13 @@ import { import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; +import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service"; +import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { TaxService } from "@bitwarden/common/billing/services/tax.service"; import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; @@ -1063,6 +1065,11 @@ const safeProviders: SafeProvider[] = [ // subscribes to sync notifications and will update itself based on that. deps: [ApiServiceAbstraction, SyncService], }), + safeProvider({ + provide: OrganizationSponsorshipApiServiceAbstraction, + useClass: OrganizationSponsorshipApiService, + deps: [ApiServiceAbstraction], + }), safeProvider({ provide: OrganizationBillingApiServiceAbstraction, useClass: OrganizationBillingApiService, diff --git a/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts b/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts index 19e993487c2..726bd6a85e1 100644 --- a/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts +++ b/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts @@ -6,5 +6,6 @@ export class OrganizationSponsorshipCreateRequest { sponsoredEmail: string; planSponsorshipType: PlanSponsorshipType; friendlyName: string; + isAdminInitiated?: boolean; notes?: string; } diff --git a/libs/common/src/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction.ts b/libs/common/src/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction.ts new file mode 100644 index 00000000000..e6e395c69df --- /dev/null +++ b/libs/common/src/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction.ts @@ -0,0 +1,8 @@ +import { ListResponse } from "../../../models/response/list.response"; +import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response"; + +export abstract class OrganizationSponsorshipApiServiceAbstraction { + abstract getOrganizationSponsorship( + sponsoredOrgId: string, + ): Promise>; +} diff --git a/libs/common/src/billing/models/response/organization-sponsorship-invites.response.ts b/libs/common/src/billing/models/response/organization-sponsorship-invites.response.ts new file mode 100644 index 00000000000..87a2cae4699 --- /dev/null +++ b/libs/common/src/billing/models/response/organization-sponsorship-invites.response.ts @@ -0,0 +1,31 @@ +import { BaseResponse } from "../../../models/response/base.response"; +import { PlanSponsorshipType } from "../../enums"; + +export class OrganizationSponsorshipInvitesResponse extends BaseResponse { + sponsoringOrganizationUserId: string; + friendlyName: string; + offeredToEmail: string; + planSponsorshipType: PlanSponsorshipType; + lastSyncDate?: Date; + validUntil?: Date; + toDelete = false; + isAdminInitiated: boolean; + notes: string; + statusMessage?: string; + statusClass?: string; + + constructor(response: any) { + super(response); + this.sponsoringOrganizationUserId = this.getResponseProperty("SponsoringOrganizationUserId"); + this.friendlyName = this.getResponseProperty("FriendlyName"); + this.offeredToEmail = this.getResponseProperty("OfferedToEmail"); + this.planSponsorshipType = this.getResponseProperty("PlanSponsorshipType"); + this.lastSyncDate = this.getResponseProperty("LastSyncDate"); + this.validUntil = this.getResponseProperty("ValidUntil"); + this.toDelete = this.getResponseProperty("ToDelete") ?? false; + this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated"); + this.notes = this.getResponseProperty("Notes"); + this.statusMessage = this.getResponseProperty("StatusMessage"); + this.statusClass = this.getResponseProperty("StatusClass"); + } +} diff --git a/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts b/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts new file mode 100644 index 00000000000..bb420377439 --- /dev/null +++ b/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts @@ -0,0 +1,22 @@ +import { ApiService } from "../../../abstractions/api.service"; +import { ListResponse } from "../../../models/response/list.response"; +import { OrganizationSponsorshipApiServiceAbstraction } from "../../abstractions/organizations/organization-sponsorship-api.service.abstraction"; +import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response"; + +export class OrganizationSponsorshipApiService + implements OrganizationSponsorshipApiServiceAbstraction +{ + constructor(private apiService: ApiService) {} + async getOrganizationSponsorship( + sponsoredOrgId: string, + ): Promise> { + const r = await this.apiService.send( + "GET", + "/organization/sponsorship/" + sponsoredOrgId + "/sponsored", + null, + true, + true, + ); + return new ListResponse(r, OrganizationSponsorshipInvitesResponse); + } +} From c9dcba2506b1d811cfcfd91b58f0d794b7b570a4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 09:00:40 -0700 Subject: [PATCH 07/16] [deps]: Update docker/setup-qemu-action action to v3.6.0 (#14504) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-web.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 4ca6dc25aab..630e1e55682 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -196,7 +196,7 @@ jobs: } - name: Set up QEMU emulators - uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 From a50b45c505bd839d965856429543b5310c2f647b Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 1 May 2025 17:12:28 +0100 Subject: [PATCH 08/16] Resolve the typo (#14584) --- .../billing/members/free-bitwarden-families.component.html | 2 +- .../app/billing/settings/sponsored-families.component.html | 2 +- apps/web/src/locales/en/messages.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.html b/apps/web/src/app/billing/members/free-bitwarden-families.component.html index a156eb608d5..9e32fb925a8 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.html +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.html @@ -11,7 +11,7 @@ {{ "sponsorshipFreeBitwardenFamilies" | i18n }}

- {{ "sponsoredFamiliesInclude" | i18n }}: + {{ "sponsoredFamiliesIncludeMessage" | i18n }}: