From ebf51ebaaf1e75561fd97a3ea77afeed55b6a582 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Thu, 14 Mar 2024 11:37:57 -0400 Subject: [PATCH 01/41] [PM-5433] Migrate Showcards and Showidentities on current tab to state provider (#8252) * added showCards and Identities to vault settings and then added migration file * added migration file and removed fields from domain * fixed merge conflicts --- .../src/popup/settings/options.component.ts | 10 +- .../components/vault/current-tab.component.ts | 10 +- .../platform/abstractions/state.service.ts | 4 - .../src/platform/models/domain/account.ts | 2 - .../src/platform/services/state.service.ts | 36 ----- libs/common/src/state-migrations/migrate.ts | 6 +- ...ard-and-identity-to-state-provider.spec.ts | 142 ++++++++++++++++++ ...how-card-and-identity-to-state-provider.ts | 105 +++++++++++++ .../vault-settings/vault-settings.service.ts | 19 +++ .../key-state/enable-passkey.state.ts | 9 -- .../key-state/vault-settings.state.ts | 23 +++ .../vault-settings/vault-settings.service.ts | 41 ++++- 12 files changed, 343 insertions(+), 64 deletions(-) create mode 100644 libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts delete mode 100644 libs/common/src/vault/services/key-state/enable-passkey.state.ts create mode 100644 libs/common/src/vault/services/key-state/vault-settings.state.ts diff --git a/apps/browser/src/popup/settings/options.component.ts b/apps/browser/src/popup/settings/options.component.ts index 813eeda144a..a4260b688f3 100644 --- a/apps/browser/src/popup/settings/options.component.ts +++ b/apps/browser/src/popup/settings/options.component.ts @@ -114,8 +114,10 @@ export class OptionsComponent implements OnInit { this.autofillSettingsService.enableContextMenu$, ); - this.showCardsCurrentTab = !(await this.stateService.getDontShowCardsCurrentTab()); - this.showIdentitiesCurrentTab = !(await this.stateService.getDontShowIdentitiesCurrentTab()); + this.showCardsCurrentTab = await firstValueFrom(this.vaultSettingsService.showCardsCurrentTab$); + this.showIdentitiesCurrentTab = await firstValueFrom( + this.vaultSettingsService.showIdentitiesCurrentTab$, + ); this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$); @@ -178,11 +180,11 @@ export class OptionsComponent implements OnInit { } async updateShowCardsCurrentTab() { - await this.stateService.setDontShowCardsCurrentTab(!this.showCardsCurrentTab); + await this.vaultSettingsService.setShowCardsCurrentTab(this.showCardsCurrentTab); } async updateShowIdentitiesCurrentTab() { - await this.stateService.setDontShowIdentitiesCurrentTab(!this.showIdentitiesCurrentTab); + await this.vaultSettingsService.setShowIdentitiesCurrentTab(this.showIdentitiesCurrentTab); } async saveTheme() { diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts index d1fcb5d4392..e4fdc7525e8 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts @@ -10,10 +10,10 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -65,11 +65,11 @@ export class CurrentTabComponent implements OnInit, OnDestroy { private changeDetectorRef: ChangeDetectorRef, private syncService: SyncService, private searchService: SearchService, - private stateService: StateService, private autofillSettingsService: AutofillSettingsServiceAbstraction, private passwordRepromptService: PasswordRepromptService, private organizationService: OrganizationService, private vaultFilterService: VaultFilterService, + private vaultSettingsService: VaultSettingsService, ) {} async ngOnInit() { @@ -271,8 +271,10 @@ export class CurrentTabComponent implements OnInit, OnDestroy { }); const otherTypes: CipherType[] = []; - const dontShowCards = await this.stateService.getDontShowCardsCurrentTab(); - const dontShowIdentities = await this.stateService.getDontShowIdentitiesCurrentTab(); + const dontShowCards = !(await firstValueFrom(this.vaultSettingsService.showCardsCurrentTab$)); + const dontShowIdentities = !(await firstValueFrom( + this.vaultSettingsService.showIdentitiesCurrentTab$, + )); this.showOrganizations = this.organizationService.hasOrganizations(); if (!dontShowCards) { otherTypes.push(CipherType.Card); diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index d9b18e509b8..1ec764974f7 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -192,10 +192,6 @@ export abstract class StateService { setDisableFavicon: (value: boolean, options?: StorageOptions) => Promise; getDisableGa: (options?: StorageOptions) => Promise; setDisableGa: (value: boolean, options?: StorageOptions) => Promise; - getDontShowCardsCurrentTab: (options?: StorageOptions) => Promise; - setDontShowCardsCurrentTab: (value: boolean, options?: StorageOptions) => Promise; - getDontShowIdentitiesCurrentTab: (options?: StorageOptions) => Promise; - setDontShowIdentitiesCurrentTab: (value: boolean, options?: StorageOptions) => Promise; getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise; getDeviceKey: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 2c3c2eab672..0c85307032d 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -198,8 +198,6 @@ export class AccountSettings { autoConfirmFingerPrints?: boolean; defaultUriMatch?: UriMatchStrategySetting; disableGa?: boolean; - dontShowCardsCurrentTab?: boolean; - dontShowIdentitiesCurrentTab?: boolean; enableAlwaysOnTop?: boolean; enableBiometric?: boolean; minimizeOnCopyToClipboard?: boolean; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index d7d302db4c6..6722cb93477 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -852,42 +852,6 @@ export class StateService< ); } - async getDontShowCardsCurrentTab(options?: StorageOptions): Promise { - return ( - (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.settings?.dontShowCardsCurrentTab ?? false - ); - } - - async setDontShowCardsCurrentTab(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.settings.dontShowCardsCurrentTab = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - - async getDontShowIdentitiesCurrentTab(options?: StorageOptions): Promise { - return ( - (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.settings?.dontShowIdentitiesCurrentTab ?? false - ); - } - - async setDontShowIdentitiesCurrentTab(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.settings.dontShowIdentitiesCurrentTab = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getDuckDuckGoSharedKey(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); if (options?.userId == null) { diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 6fb7c0288cb..1051ee952bc 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -31,6 +31,7 @@ import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-langua import { AppIdMigrator } from "./migrations/33-move-app-id-to-state-providers"; import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-to-state-providers"; import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to-state-providers"; +import { VaultSettingsKeyMigrator } from "./migrations/36-move-show-card-and-identity-to-state-provider"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; @@ -40,7 +41,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 2; -export const CURRENT_VERSION = 35; +export const CURRENT_VERSION = 36; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -78,7 +79,8 @@ export function createMigrationBuilder() { .with(PreferredLanguageMigrator, 31, 32) .with(AppIdMigrator, 32, 33) .with(DomainSettingsMigrator, 33, 34) - .with(MoveThemeToStateProviderMigrator, 34, CURRENT_VERSION); + .with(MoveThemeToStateProviderMigrator, 34, 35) + .with(VaultSettingsKeyMigrator, 35, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts new file mode 100644 index 00000000000..64a7fd8efa1 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts @@ -0,0 +1,142 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper, StateDefinitionLike } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { VaultSettingsKeyMigrator } from "./36-move-show-card-and-identity-to-state-provider"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2", "user-3"], + "user-1": { + settings: { + dontShowCardsCurrentTab: true, + dontShowIdentitiesCurrentTab: true, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + settings: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + "user_user-1_vaultSettings_showCardsCurrentTab": true, + "user_user-1_vaultSettings_showIdentitiesCurrentTab": true, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2", "user-3"], + "user-1": { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + settings: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +const vaultSettingsStateDefinition: { + stateDefinition: StateDefinitionLike; +} = { + stateDefinition: { + name: "vaultSettings", + }, +}; + +describe("VaultSettingsKeyMigrator", () => { + let helper: MockProxy; + let sut: VaultSettingsKeyMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 35); + sut = new VaultSettingsKeyMigrator(35, 36); + }); + + it("should remove dontShowCardsCurrentTab and dontShowIdentitiesCurrentTab from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("user-1", { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should set showCardsCurrentTab and showIdentitiesCurrentTab values for each account", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledTimes(2); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + { ...vaultSettingsStateDefinition, key: "showCardsCurrentTab" }, + true, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + { ...vaultSettingsStateDefinition, key: "showIdentitiesCurrentTab" }, + true, + ); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 36); + sut = new VaultSettingsKeyMigrator(35, 36); + }); + + it("should null out new values for each account", async () => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledTimes(2); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + { ...vaultSettingsStateDefinition, key: "showCardsCurrentTab" }, + null, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + { ...vaultSettingsStateDefinition, key: "showIdentitiesCurrentTab" }, + null, + ); + }); + + it("should add explicit value back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("user-1", { + settings: { + otherStuff: "otherStuff2", + dontShowCardsCurrentTab: false, + dontShowIdentitiesCurrentTab: false, + }, + otherStuff: "otherStuff3", + }); + }); + + it("should not try to restore values to missing accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith("user-3", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts b/libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts new file mode 100644 index 00000000000..572e074cf1c --- /dev/null +++ b/libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts @@ -0,0 +1,105 @@ +import { MigrationHelper, StateDefinitionLike } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountState = { + settings?: { + dontShowCardsCurrentTab?: boolean; + dontShowIdentitiesCurrentTab?: boolean; + }; +}; + +const vaultSettingsStateDefinition: { + stateDefinition: StateDefinitionLike; +} = { + stateDefinition: { + name: "vaultSettings", + }, +}; + +export class VaultSettingsKeyMigrator extends Migrator<35, 36> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + + async function migrateAccount(userId: string, account: ExpectedAccountState): Promise { + let updateAccount = false; + const accountSettings = account?.settings; + + if (accountSettings?.dontShowCardsCurrentTab != null) { + await helper.setToUser( + userId, + { ...vaultSettingsStateDefinition, key: "showCardsCurrentTab" }, + accountSettings.dontShowCardsCurrentTab, + ); + delete account.settings.dontShowCardsCurrentTab; + updateAccount = true; + } + + if (accountSettings?.dontShowIdentitiesCurrentTab != null) { + await helper.setToUser( + userId, + { ...vaultSettingsStateDefinition, key: "showIdentitiesCurrentTab" }, + accountSettings.dontShowIdentitiesCurrentTab, + ); + delete account.settings.dontShowIdentitiesCurrentTab; + updateAccount = true; + } + + if (updateAccount) { + await helper.set(userId, account); + } + } + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + + async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise { + let updateAccount = false; + let settings = account?.settings ?? {}; + + const showCardsCurrentTab = await helper.getFromUser(userId, { + ...vaultSettingsStateDefinition, + key: "showCardsCurrentTab", + }); + + const showIdentitiesCurrentTab = await helper.getFromUser(userId, { + ...vaultSettingsStateDefinition, + key: "showIdentitiesCurrentTab", + }); + + if (showCardsCurrentTab != null) { + // invert the value to match the new naming convention + settings = { ...settings, dontShowCardsCurrentTab: !showCardsCurrentTab }; + + await helper.setToUser( + userId, + { ...vaultSettingsStateDefinition, key: "showCardsCurrentTab" }, + null, + ); + + updateAccount = true; + } + + if (showIdentitiesCurrentTab != null) { + // invert the value to match the new naming convention + settings = { ...settings, dontShowIdentitiesCurrentTab: !showIdentitiesCurrentTab }; + + await helper.setToUser( + userId, + { ...vaultSettingsStateDefinition, key: "showIdentitiesCurrentTab" }, + null, + ); + + updateAccount = true; + } + + if (updateAccount) { + await helper.set(userId, { ...account, settings }); + } + } + } +} diff --git a/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts b/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts index 9e935b763cf..e3132d9ae1f 100644 --- a/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts +++ b/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts @@ -8,10 +8,29 @@ export abstract class VaultSettingsService { * The observable updates when the setting changes. */ enablePasskeys$: Observable; + /** + * An observable monitoring the state of the show cards on the current tab. + */ + showCardsCurrentTab$: Observable; + /** + * An observable monitoring the state of the show identities on the current tab. + */ + showIdentitiesCurrentTab$: Observable; + /** /** * Saves the enable passkeys setting to disk. * @param value The new value for the passkeys setting. */ setEnablePasskeys: (value: boolean) => Promise; + /** + * Saves the show cards on tab page setting to disk. + * @param value The new value for the show cards on tab page setting. + */ + setShowCardsCurrentTab: (value: boolean) => Promise; + /** + * Saves the show identities on tab page setting to disk. + * @param value The new value for the show identities on tab page setting. + */ + setShowIdentitiesCurrentTab: (value: boolean) => Promise; } diff --git a/libs/common/src/vault/services/key-state/enable-passkey.state.ts b/libs/common/src/vault/services/key-state/enable-passkey.state.ts deleted file mode 100644 index dccbf8fd11f..00000000000 --- a/libs/common/src/vault/services/key-state/enable-passkey.state.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { VAULT_SETTINGS_DISK, KeyDefinition } from "../../../platform/state"; - -export const USER_ENABLE_PASSKEYS = new KeyDefinition( - VAULT_SETTINGS_DISK, - "enablePasskeys", - { - deserializer: (obj) => obj, - }, -); diff --git a/libs/common/src/vault/services/key-state/vault-settings.state.ts b/libs/common/src/vault/services/key-state/vault-settings.state.ts new file mode 100644 index 00000000000..90b47912eee --- /dev/null +++ b/libs/common/src/vault/services/key-state/vault-settings.state.ts @@ -0,0 +1,23 @@ +import { VAULT_SETTINGS_DISK, KeyDefinition } from "../../../platform/state"; + +export const USER_ENABLE_PASSKEYS = new KeyDefinition( + VAULT_SETTINGS_DISK, + "enablePasskeys", + { + deserializer: (obj) => obj, + }, +); + +export const SHOW_CARDS_CURRENT_TAB = new KeyDefinition( + VAULT_SETTINGS_DISK, + "showCardsCurrentTab", + { + deserializer: (obj) => obj, + }, +); + +export const SHOW_IDENTITIES_CURRENT_TAB = new KeyDefinition( + VAULT_SETTINGS_DISK, + "showIdentitiesCurrentTab", + { deserializer: (obj) => obj }, +); diff --git a/libs/common/src/vault/services/vault-settings/vault-settings.service.ts b/libs/common/src/vault/services/vault-settings/vault-settings.service.ts index 22192c271bf..39b96318217 100644 --- a/libs/common/src/vault/services/vault-settings/vault-settings.service.ts +++ b/libs/common/src/vault/services/vault-settings/vault-settings.service.ts @@ -1,8 +1,12 @@ import { Observable, map } from "rxjs"; -import { GlobalState, StateProvider } from "../../../platform/state"; +import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service"; -import { USER_ENABLE_PASSKEYS } from "../key-state/enable-passkey.state"; +import { + SHOW_CARDS_CURRENT_TAB, + SHOW_IDENTITIES_CURRENT_TAB, + USER_ENABLE_PASSKEYS, +} from "../key-state/vault-settings.state"; /** * {@link VaultSettingsServiceAbstraction} @@ -10,7 +14,6 @@ import { USER_ENABLE_PASSKEYS } from "../key-state/enable-passkey.state"; export class VaultSettingsService implements VaultSettingsServiceAbstraction { private enablePasskeysState: GlobalState = this.stateProvider.getGlobal(USER_ENABLE_PASSKEYS); - /** * {@link VaultSettingsServiceAbstraction.enablePasskeys$} */ @@ -18,8 +21,40 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction { map((x) => x ?? true), ); + private showCardsCurrentTabState: ActiveUserState = + this.stateProvider.getActive(SHOW_CARDS_CURRENT_TAB); + /** + * {@link VaultSettingsServiceAbstraction.showCardsCurrentTab$} + */ + readonly showCardsCurrentTab$: Observable = this.showCardsCurrentTabState.state$.pipe( + map((x) => x ?? true), + ); + + private showIdentitiesCurrentTabState: ActiveUserState = this.stateProvider.getActive( + SHOW_IDENTITIES_CURRENT_TAB, + ); + /** + * {@link VaultSettingsServiceAbstraction.showIdentitiesCurrentTab$} + */ + readonly showIdentitiesCurrentTab$: Observable = + this.showIdentitiesCurrentTabState.state$.pipe(map((x) => x ?? true)); + constructor(private stateProvider: StateProvider) {} + /** + * {@link VaultSettingsServiceAbstraction.setShowCardsCurrentTab} + */ + async setShowCardsCurrentTab(value: boolean): Promise { + await this.showCardsCurrentTabState.update(() => value); + } + + /** + * {@link VaultSettingsServiceAbstraction.setDontShowIdentitiesCurrentTab} + */ + async setShowIdentitiesCurrentTab(value: boolean): Promise { + await this.showIdentitiesCurrentTabState.update(() => value); + } + /** * {@link VaultSettingsServiceAbstraction.setEnablePasskeys} */ From 10d503c15f277f3da11073d622727ce6fb5acb49 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Thu, 14 Mar 2024 11:06:07 -0500 Subject: [PATCH 02/41] [PM-6501] Search field disqualifications preventing filling password input fields (#8117) * [PM-6501] Search field disqualifications preventing filling password input fields * [PM-6501] Reworking implementation of AutofillService.isSearchField to more carefully test search field attribute keywords * [PM-6501] Reworking implementation of AutofillService.isSearchField to more carefully test search field attribute keywords * [PM-6501] Reworking implementation of AutofillService.isSearchField to more carefully test search field attribute keywords --- .../services/autofill.service.spec.ts | 28 +++++++++ .../src/autofill/services/autofill.service.ts | 62 +++++++++++-------- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 2a9519292e7..eb70f0f7dc4 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -3380,6 +3380,34 @@ describe("AutofillService", () => { expect(value).toBe(false); }); + + it("validates attribute identifiers with mixed camel case and non-alpha characters", () => { + const attributes: Record = { + _$1_go_look: true, + go_look: true, + goLook: true, + go1look: true, + "go look": true, + look_go: true, + findPerson: true, + query$1: true, + look_goo: false, + golook: false, + lookgo: false, + logonField: false, + ego_input: false, + "Gold Password": false, + searching_for: false, + person_finder: false, + }; + const autofillFieldMocks = Object.keys(attributes).map((key) => + createAutofillFieldMock({ htmlID: key }), + ); + autofillFieldMocks.forEach((field) => { + const value = AutofillService["isSearchField"](field); + expect(value).toBe(attributes[field.htmlID]); + }); + }); }); describe("isFieldMatch", () => { diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 3cb73dd72be..3a809af0c38 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -44,6 +44,7 @@ export default class AutofillService implements AutofillServiceInterface { private openPasswordRepromptPopoutDebounce: NodeJS.Timeout; private currentlyOpeningPasswordRepromptPopout = false; private autofillScriptPortsSet = new Set(); + static searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames); constructor( private cipherService: CipherService, @@ -1380,11 +1381,33 @@ export default class AutofillService implements AutofillServiceInterface { return excludedTypes.indexOf(type) > -1; } + /** + * Identifies if a passed field contains text artifacts that identify it as a search field. + * + * @param field - The autofill field that we are validating as a search field + */ private static isSearchField(field: AutofillField) { const matchFieldAttributeValues = [field.type, field.htmlName, field.htmlID, field.placeholder]; - const matchPattern = new RegExp(AutoFillConstants.SearchFieldNames.join("|"), "gi"); + for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) { + if (!matchFieldAttributeValues[attrIndex]) { + continue; + } - return Boolean(matchFieldAttributeValues.join(" ").match(matchPattern)); + // Separate camel case words and case them to lower case values + const camelCaseSeparatedFieldAttribute = matchFieldAttributeValues[attrIndex] + .replace(/([a-z])([A-Z])/g, "$1 $2") + .toLowerCase(); + // Split the attribute by non-alphabetical characters to get the keywords + const attributeKeywords = camelCaseSeparatedFieldAttribute.split(/[^a-z]/gi); + + for (let keywordIndex = 0; keywordIndex < attributeKeywords.length; keywordIndex++) { + if (AutofillService.searchFieldNamesSet.has(attributeKeywords[keywordIndex])) { + return true; + } + } + } + + return false; } static isExcludedFieldType(field: AutofillField, excludedTypes: string[]) { @@ -1397,11 +1420,7 @@ export default class AutofillService implements AutofillServiceInterface { } // Check if the input is an untyped/mistyped search input - if (this.isSearchField(field)) { - return true; - } - - return false; + return this.isSearchField(field); } /** @@ -1525,11 +1544,7 @@ export default class AutofillService implements AutofillServiceInterface { return false; } - if (AutoFillConstants.PasswordFieldExcludeList.some((i) => cleanedValue.indexOf(i) > -1)) { - return false; - } - - return true; + return !AutoFillConstants.PasswordFieldExcludeList.some((i) => cleanedValue.indexOf(i) > -1); } static fieldHasDisqualifyingAttributeValue(field: AutofillField) { @@ -1572,7 +1587,11 @@ export default class AutofillService implements AutofillServiceInterface { const arr: AutofillField[] = []; pageDetails.fields.forEach((f) => { - if (AutofillService.isExcludedFieldType(f, AutoFillConstants.ExcludedAutofillLoginTypes)) { + const isPassword = f.type === "password"; + if ( + !isPassword && + AutofillService.isExcludedFieldType(f, AutoFillConstants.ExcludedAutofillLoginTypes) + ) { return; } @@ -1581,23 +1600,16 @@ export default class AutofillService implements AutofillServiceInterface { return; } - const isPassword = f.type === "password"; - const isLikePassword = () => { if (f.type !== "text") { return false; } - if (AutofillService.valueIsLikePassword(f.htmlID)) { - return true; - } - - if (AutofillService.valueIsLikePassword(f.htmlName)) { - return true; - } - - if (AutofillService.valueIsLikePassword(f.placeholder)) { - return true; + const testedValues = [f.htmlID, f.htmlName, f.placeholder]; + for (let i = 0; i < testedValues.length; i++) { + if (AutofillService.valueIsLikePassword(testedValues[i])) { + return true; + } } return false; From 65b7ca7177d92e05280919fc4df84b9f410d4da7 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 14 Mar 2024 09:56:48 -0700 Subject: [PATCH 03/41] [PM-5266] Create Avatar Service (#7905) * rename file, move, and update imports * refactor and implement StateProvider * remove comments * add migration * use 'disk-local' for web * add JSDoc comments * move AvatarService before SyncService * create factory * replace old method with observable in story * fix tests * add tests for migration * receive most recent avatarColor emission * move logic to component * fix CLI dependency * remove BehaviorSubject * cleanup * use UserKeyDefinition * avoid extra write * convert to observable * fix tests --- .../avatar-service.factory.ts | 38 +++++ .../current-account.component.ts | 45 +++++- .../services/account-switcher.service.spec.ts | 12 +- .../services/account-switcher.service.ts | 8 +- .../services/current-account.service.ts | 44 ------ .../browser/src/background/main.background.ts | 13 +- .../src/background/runtime.background.ts | 3 - apps/cli/src/bw.ts | 6 + .../app/layout/account-switcher.component.ts | 9 +- .../account/change-avatar.component.ts | 12 +- .../components/dynamic-avatar.component.ts | 10 +- .../vault-items/vault-items.stories.ts | 12 +- .../organization-name-badge.component.ts | 7 +- .../src/services/jslib-services.module.ts | 11 +- .../account/avatar-update.service.ts | 8 - .../src/auth/abstractions/avatar.service.ts | 29 ++++ .../src/auth/services/avatar.service.ts | 33 ++++ .../platform/abstractions/state.service.ts | 3 - .../src/platform/services/state.service.ts | 17 --- .../src/platform/state/state-definitions.ts | 1 + .../services/account/avatar-update.service.ts | 37 ----- libs/common/src/state-migrations/migrate.ts | 6 +- ...ve-avatar-color-to-state-providers.spec.ts | 143 ++++++++++++++++++ ...37-move-avatar-color-to-state-providers.ts | 57 +++++++ .../src/vault/services/sync/sync.service.ts | 4 +- 25 files changed, 403 insertions(+), 165 deletions(-) create mode 100644 apps/browser/src/auth/background/service-factories/avatar-service.factory.ts delete mode 100644 apps/browser/src/auth/popup/account-switching/services/current-account.service.ts delete mode 100644 libs/common/src/abstractions/account/avatar-update.service.ts create mode 100644 libs/common/src/auth/abstractions/avatar.service.ts create mode 100644 libs/common/src/auth/services/avatar.service.ts delete mode 100644 libs/common/src/services/account/avatar-update.service.ts create mode 100644 libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts diff --git a/apps/browser/src/auth/background/service-factories/avatar-service.factory.ts b/apps/browser/src/auth/background/service-factories/avatar-service.factory.ts new file mode 100644 index 00000000000..456edfa93da --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/avatar-service.factory.ts @@ -0,0 +1,38 @@ +import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; +import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; + +import { + ApiServiceInitOptions, + apiServiceFactory, +} from "../../../platform/background/service-factories/api-service.factory"; +import { + CachedServices, + factory, + FactoryOptions, +} from "../../../platform/background/service-factories/factory-options"; +import { + stateProviderFactory, + StateProviderInitOptions, +} from "../../../platform/background/service-factories/state-provider.factory"; + +type AvatarServiceFactoryOptions = FactoryOptions; + +export type AvatarServiceInitOptions = AvatarServiceFactoryOptions & + ApiServiceInitOptions & + StateProviderInitOptions; + +export function avatarServiceFactory( + cache: { avatarService?: AvatarServiceAbstraction } & CachedServices, + opts: AvatarServiceInitOptions, +): Promise { + return factory( + cache, + "avatarService", + opts, + async () => + new AvatarService( + await apiServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), + ), + ); +} diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.ts b/apps/browser/src/auth/popup/account-switching/current-account.component.ts index 06612b8d338..1c7f93bf304 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.ts +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.ts @@ -1,32 +1,61 @@ import { Location } from "@angular/common"; import { Component } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; +import { Observable, combineLatest, switchMap } from "rxjs"; -import { CurrentAccountService } from "./services/current-account.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { UserId } from "@bitwarden/common/types/guid"; + +export type CurrentAccount = { + id: UserId; + name: string | undefined; + email: string; + status: AuthenticationStatus; + avatarColor: string; +}; @Component({ selector: "app-current-account", templateUrl: "current-account.component.html", }) export class CurrentAccountComponent { + currentAccount$: Observable; + constructor( - private currentAccountService: CurrentAccountService, + private accountService: AccountService, + private avatarService: AvatarService, private router: Router, private location: Location, private route: ActivatedRoute, - ) {} + ) { + this.currentAccount$ = combineLatest([ + this.accountService.activeAccount$, + this.avatarService.avatarColor$, + ]).pipe( + switchMap(async ([account, avatarColor]) => { + if (account == null) { + return null; + } + const currentAccount: CurrentAccount = { + id: account.id, + name: account.name || account.email, + email: account.email, + status: account.status, + avatarColor, + }; - get currentAccount$() { - return this.currentAccountService.currentAccount$; + return currentAccount; + }), + ); } async currentAccountClicked() { if (this.route.snapshot.data.state.includes("account-switcher")) { this.location.back(); } else { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/account-switcher"]); + await this.router.navigate(["/account-switcher"]); } } } diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts index 9845fac1dad..f02a8ee2016 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts @@ -1,12 +1,12 @@ import { matches, mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, timeout } from "rxjs"; +import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { UserId } from "@bitwarden/common/types/guid"; import { AccountSwitcherService } from "./account-switcher.service"; @@ -16,7 +16,7 @@ describe("AccountSwitcherService", () => { const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null); const accountService = mock(); - const stateService = mock(); + const avatarService = mock(); const messagingService = mock(); const environmentService = mock(); const logService = mock(); @@ -25,11 +25,13 @@ describe("AccountSwitcherService", () => { beforeEach(() => { jest.resetAllMocks(); + accountService.accounts$ = accountsSubject; accountService.activeAccount$ = activeAccountSubject; + accountSwitcherService = new AccountSwitcherService( accountService, - stateService, + avatarService, messagingService, environmentService, logService, @@ -44,6 +46,7 @@ describe("AccountSwitcherService", () => { status: AuthenticationStatus.Unlocked, }; + avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc")); accountsSubject.next({ "1": user1AccountInfo, } as Record); @@ -72,6 +75,7 @@ describe("AccountSwitcherService", () => { status: AuthenticationStatus.Unlocked, }; } + avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc")); accountsSubject.next(seedAccounts); activeAccountSubject.next( Object.assign(seedAccounts["1" as UserId], { id: "1" as UserId }), diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts index 0b1015544c1..cf78f2ff913 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts @@ -11,11 +11,11 @@ import { } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { UserId } from "@bitwarden/common/types/guid"; import { fromChromeEvent } from "../../../../platform/browser/from-chrome-event"; @@ -44,7 +44,7 @@ export class AccountSwitcherService { constructor( private accountService: AccountService, - private stateService: StateService, + private avatarService: AvatarService, private messagingService: MessagingService, private environmentService: EnvironmentService, private logService: LogService, @@ -68,7 +68,9 @@ export class AccountSwitcherService { server: await this.environmentService.getHost(id), status: account.status, isActive: id === activeAccount?.id, - avatarColor: await this.stateService.getAvatarColor({ userId: id }), + avatarColor: await firstValueFrom( + this.avatarService.getUserAvatarColor$(id as UserId), + ), }; }), ); diff --git a/apps/browser/src/auth/popup/account-switching/services/current-account.service.ts b/apps/browser/src/auth/popup/account-switching/services/current-account.service.ts deleted file mode 100644 index 21fc3bdac43..00000000000 --- a/apps/browser/src/auth/popup/account-switching/services/current-account.service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Injectable } from "@angular/core"; -import { Observable, switchMap } from "rxjs"; - -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { UserId } from "@bitwarden/common/types/guid"; - -export type CurrentAccount = { - id: UserId; - name: string | undefined; - email: string; - status: AuthenticationStatus; - avatarColor: string; -}; - -@Injectable({ - providedIn: "root", -}) -export class CurrentAccountService { - currentAccount$: Observable; - - constructor( - private accountService: AccountService, - private stateService: StateService, - ) { - this.currentAccount$ = this.accountService.activeAccount$.pipe( - switchMap(async (account) => { - if (account == null) { - return null; - } - const currentAccount: CurrentAccount = { - id: account.id, - name: account.name || account.email, - email: account.email, - status: account.status, - avatarColor: await this.stateService.getAvatarColor({ userId: account.id }), - }; - - return currentAccount; - }), - ); - } -} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 23160f8a144..fd2208fe3c8 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -8,7 +8,6 @@ import { AuthRequestServiceAbstraction, AuthRequestService, } from "@bitwarden/auth/common"; -import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -39,6 +38,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; @@ -117,7 +117,6 @@ import { DefaultStateProvider } from "@bitwarden/common/platform/state/implement import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service"; /* eslint-enable import/no-restricted-paths */ import { DefaultThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; -import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; @@ -125,6 +124,7 @@ import { EventUploadService } from "@bitwarden/common/services/event/event-uploa import { NotificationsService } from "@bitwarden/common/services/notifications.service"; import { SearchService } from "@bitwarden/common/services/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; +import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/avatar.service"; import { PasswordGenerationService, PasswordGenerationServiceAbstraction, @@ -288,7 +288,7 @@ export default class MainBackground { fido2UserInterfaceService: Fido2UserInterfaceServiceAbstraction; fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction; fido2ClientService: Fido2ClientServiceAbstraction; - avatarUpdateService: AvatarUpdateServiceAbstraction; + avatarService: AvatarServiceAbstraction; mainContextMenuHandler: MainContextMenuHandler; cipherContextMenuHandler: CipherContextMenuHandler; configService: BrowserConfigService; @@ -685,7 +685,11 @@ export default class MainBackground { this.fileUploadService, this.sendService, ); + + this.avatarService = new AvatarService(this.apiService, this.stateProvider); + this.providerService = new ProviderService(this.stateProvider); + this.syncService = new SyncService( this.apiService, this.domainSettingsService, @@ -703,6 +707,7 @@ export default class MainBackground { this.folderApiService, this.organizationService, this.sendApiService, + this.avatarService, logoutCallback, ); this.eventUploadService = new EventUploadService( @@ -943,8 +948,6 @@ export default class MainBackground { this.apiService, ); - this.avatarUpdateService = new AvatarUpdateService(this.apiService, this.stateService); - if (!this.popupOnlyContext) { this.mainContextMenuHandler = new MainContextMenuHandler( this.stateService, diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 6c0c0f169aa..55be9ba64d1 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -130,9 +130,6 @@ export default class RuntimeBackground { await this.main.refreshBadge(); await this.main.refreshMenu(); }, 2000); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.main.avatarUpdateService.loadColorFromState(); this.configService.triggerServerConfigFetch(); } break; diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 031be3b48c5..1e624de1b15 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -23,10 +23,12 @@ import { PolicyApiService } from "@bitwarden/common/admin-console/services/polic import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; @@ -216,6 +218,7 @@ export class Main { derivedStateProvider: DerivedStateProvider; stateProvider: StateProvider; loginStrategyService: LoginStrategyServiceAbstraction; + avatarService: AvatarServiceAbstraction; stateEventRunnerService: StateEventRunnerService; biometricStateService: BiometricStateService; @@ -555,6 +558,8 @@ export class Main { null, ); + this.avatarService = new AvatarService(this.apiService, this.stateProvider); + this.syncService = new SyncService( this.apiService, this.domainSettingsService, @@ -572,6 +577,7 @@ export class Main { this.folderApiService, this.organizationService, this.sendApiService, + this.avatarService, async (expired: boolean) => await this.logout(), ); diff --git a/apps/desktop/src/app/layout/account-switcher.component.ts b/apps/desktop/src/app/layout/account-switcher.component.ts index 7e6256e9ba6..795870b3054 100644 --- a/apps/desktop/src/app/layout/account-switcher.component.ts +++ b/apps/desktop/src/app/layout/account-switcher.component.ts @@ -2,9 +2,10 @@ import { animate, state, style, transition, trigger } from "@angular/animations" import { ConnectedPosition } from "@angular/cdk/overlay"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { concatMap, Subject, takeUntil } from "rxjs"; +import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -12,6 +13,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Account } from "@bitwarden/common/platform/models/domain/account"; +import { UserId } from "@bitwarden/common/types/guid"; type ActiveAccount = { id: string; @@ -84,6 +86,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { constructor( private stateService: StateService, private authService: AuthService, + private avatarService: AvatarService, private messagingService: MessagingService, private router: Router, private tokenService: TokenService, @@ -101,7 +104,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { id: await this.tokenService.getUserId(), name: (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()), email: await this.tokenService.getEmail(), - avatarColor: await this.stateService.getAvatarColor(), + avatarColor: await firstValueFrom(this.avatarService.avatarColor$), server: await this.environmentService.getHost(), }; } catch { @@ -154,7 +157,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { name: baseAccounts[userId].profile.name, email: baseAccounts[userId].profile.email, authenticationStatus: await this.authService.getAuthStatus(userId), - avatarColor: await this.stateService.getAvatarColor({ userId: userId }), + avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)), server: await this.environmentService.getHost(userId), }; } diff --git a/apps/web/src/app/auth/settings/account/change-avatar.component.ts b/apps/web/src/app/auth/settings/account/change-avatar.component.ts index 2efe554bbe4..bbcbaf6820f 100644 --- a/apps/web/src/app/auth/settings/account/change-avatar.component.ts +++ b/apps/web/src/app/auth/settings/account/change-avatar.component.ts @@ -9,9 +9,9 @@ import { ViewChild, ViewEncapsulation, } from "@angular/core"; -import { BehaviorSubject, debounceTime, Subject, takeUntil } from "rxjs"; +import { BehaviorSubject, debounceTime, firstValueFrom, Subject, takeUntil } from "rxjs"; -import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { ProfileResponse } from "@bitwarden/common/models/response/profile.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -55,7 +55,7 @@ export class ChangeAvatarComponent implements OnInit, OnDestroy { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private logService: LogService, - private accountUpdateService: AvatarUpdateService, + private avatarService: AvatarService, ) {} async ngOnInit() { @@ -73,9 +73,7 @@ export class ChangeAvatarComponent implements OnInit, OnDestroy { this.currentSelection = color; }); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.setSelection(await this.accountUpdateService.loadColorFromState()); + await this.setSelection(await firstValueFrom(this.avatarService.avatarColor$)); } async showCustomPicker() { @@ -93,7 +91,7 @@ export class ChangeAvatarComponent implements OnInit, OnDestroy { async submit() { try { if (Utils.validateHexColor(this.currentSelection) || this.currentSelection == null) { - await this.accountUpdateService.pushUpdate(this.currentSelection); + await this.avatarService.setAvatarColor(this.currentSelection); this.changeColor.emit(this.currentSelection); this.platformUtilsService.showToast("success", null, this.i18nService.t("avatarUpdated")); } else { diff --git a/apps/web/src/app/components/dynamic-avatar.component.ts b/apps/web/src/app/components/dynamic-avatar.component.ts index e9eea873a57..4cdfda4eba8 100644 --- a/apps/web/src/app/components/dynamic-avatar.component.ts +++ b/apps/web/src/app/components/dynamic-avatar.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnDestroy } from "@angular/core"; -import { Observable, Subject } from "rxjs"; +import { Subject } from "rxjs"; -import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { SharedModule } from "../shared"; @@ -29,14 +29,14 @@ export class DynamicAvatarComponent implements OnDestroy { @Input() text: string; @Input() title: string; @Input() size: SizeTypes = "default"; - color$: Observable; private destroy$ = new Subject(); - constructor(private accountUpdateService: AvatarUpdateService) { + color$ = this.avatarService.avatarColor$; + + constructor(private avatarService: AvatarService) { if (this.text) { this.text = this.text.toUpperCase(); } - this.color$ = this.accountUpdateService.avatarUpdate$; } async ngOnDestroy() { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 33486fcbb81..8f33c501646 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -1,12 +1,12 @@ import { importProvidersFrom } from "@angular/core"; import { RouterModule } from "@angular/router"; import { applicationConfig, Meta, moduleMetadata, Story } from "@storybook/angular"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; -import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -72,12 +72,10 @@ export default { } as Partial, }, { - provide: AvatarUpdateService, + provide: AvatarService, useValue: { - async loadColorFromState() { - return "#FF0000"; - }, - } as Partial, + avatarColor$: of("#FF0000"), + } as Partial, }, { provide: TokenService, diff --git a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts index 954e558a1d9..6d53b8ad720 100644 --- a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts +++ b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts @@ -1,6 +1,7 @@ import { Component, Input, OnChanges } from "@angular/core"; +import { firstValueFrom } from "rxjs"; -import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -24,7 +25,7 @@ export class OrganizationNameBadgeComponent implements OnChanges { constructor( private i18nService: I18nService, - private avatarService: AvatarUpdateService, + private avatarService: AvatarService, private tokenService: TokenService, ) {} @@ -35,7 +36,7 @@ export class OrganizationNameBadgeComponent implements OnChanges { if (this.isMe) { this.name = this.i18nService.t("me"); - this.color = await this.avatarService.loadColorFromState(); + this.color = await firstValueFrom(this.avatarService.avatarColor$); if (this.color == null) { const userId = await this.tokenService.getUserId(); if (userId != null) { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 743aead0ca9..7aecdccbea3 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -9,7 +9,6 @@ import { LoginStrategyServiceAbstraction, LoginStrategyService, } from "@bitwarden/auth/common"; -import { AvatarUpdateService as AccountUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -51,6 +50,7 @@ import { } from "@bitwarden/common/auth/abstractions/account.service"; import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; @@ -69,6 +69,7 @@ import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; @@ -163,7 +164,6 @@ import { DefaultThemeStateService, ThemeStateService, } from "@bitwarden/common/platform/theming/theme-state.service"; -import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; @@ -452,9 +452,9 @@ const typesafeProviders: Array = [ useExisting: InternalAccountService, }), safeProvider({ - provide: AccountUpdateServiceAbstraction, - useClass: AvatarUpdateService, - deps: [ApiServiceAbstraction, StateServiceAbstraction], + provide: AvatarServiceAbstraction, + useClass: AvatarService, + deps: [ApiServiceAbstraction, StateProvider], }), safeProvider({ provide: LogService, useFactory: () => new ConsoleLogService(false), deps: [] }), safeProvider({ @@ -561,6 +561,7 @@ const typesafeProviders: Array = [ FolderApiServiceAbstraction, InternalOrganizationServiceAbstraction, SendApiServiceAbstraction, + AvatarServiceAbstraction, LOGOUT_CALLBACK, ], }), diff --git a/libs/common/src/abstractions/account/avatar-update.service.ts b/libs/common/src/abstractions/account/avatar-update.service.ts deleted file mode 100644 index 980942dfe39..00000000000 --- a/libs/common/src/abstractions/account/avatar-update.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Observable } from "rxjs"; - -import { ProfileResponse } from "../../models/response/profile.response"; -export abstract class AvatarUpdateService { - avatarUpdate$ = new Observable(); - abstract pushUpdate(color: string): Promise; - abstract loadColorFromState(): Promise; -} diff --git a/libs/common/src/auth/abstractions/avatar.service.ts b/libs/common/src/auth/abstractions/avatar.service.ts new file mode 100644 index 00000000000..1192ef745df --- /dev/null +++ b/libs/common/src/auth/abstractions/avatar.service.ts @@ -0,0 +1,29 @@ +import { Observable } from "rxjs"; + +import { UserId } from "../../types/guid"; + +export abstract class AvatarService { + /** + * An observable monitoring the active user's avatar color. + * The observable updates when the avatar color changes. + */ + avatarColor$: Observable; + /** + * Sets the avatar color of the active user + * + * @param color the color to set the avatar color to + * @returns a promise that resolves when the avatar color is set + */ + abstract setAvatarColor(color: string): Promise; + /** + * Gets the avatar color of the specified user. + * + * @remarks This is most useful for account switching where we show an + * avatar for each account. If you only need the active user's + * avatar color, use the avatarColor$ observable above instead. + * + * @param userId the userId of the user whose avatar color should be retreived + * @return an Observable that emits a string of the avatar color of the specified user + */ + abstract getUserAvatarColor$(userId: UserId): Observable; +} diff --git a/libs/common/src/auth/services/avatar.service.ts b/libs/common/src/auth/services/avatar.service.ts new file mode 100644 index 00000000000..b770dc39b98 --- /dev/null +++ b/libs/common/src/auth/services/avatar.service.ts @@ -0,0 +1,33 @@ +import { Observable } from "rxjs"; + +import { ApiService } from "../../abstractions/api.service"; +import { UpdateAvatarRequest } from "../../models/request/update-avatar.request"; +import { AVATAR_DISK, StateProvider, UserKeyDefinition } from "../../platform/state"; +import { UserId } from "../../types/guid"; +import { AvatarService as AvatarServiceAbstraction } from "../abstractions/avatar.service"; + +const AVATAR_COLOR = new UserKeyDefinition(AVATAR_DISK, "avatarColor", { + deserializer: (value) => value, + clearOn: [], +}); + +export class AvatarService implements AvatarServiceAbstraction { + avatarColor$: Observable; + + constructor( + private apiService: ApiService, + private stateProvider: StateProvider, + ) { + this.avatarColor$ = this.stateProvider.getActive(AVATAR_COLOR).state$; + } + + async setAvatarColor(color: string): Promise { + const { avatarColor } = await this.apiService.putAvatar(new UpdateAvatarRequest(color)); + + await this.stateProvider.setUserState(AVATAR_COLOR, avatarColor); + } + + getUserAvatarColor$(userId: UserId): Observable { + return this.stateProvider.getUser(userId, AVATAR_COLOR).state$; + } +} diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 1ec764974f7..3fc65e4acf1 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -359,9 +359,6 @@ export abstract class StateService { * @deprecated Do not call this directly, use ConfigService */ setServerConfig: (value: ServerConfigData, options?: StorageOptions) => Promise; - - getAvatarColor: (options?: StorageOptions) => Promise; - setAvatarColor: (value: string, options?: StorageOptions) => Promise; /** * fetches string value of URL user tried to navigate to while unauthenticated. * @param options Defines the storage options for the URL; Defaults to session Storage. diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 6722cb93477..08c5350d06d 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -1854,23 +1854,6 @@ export class StateService< )?.settings?.serverConfig; } - async getAvatarColor(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) - )?.settings?.avatarColor; - } - - async setAvatarColor(value: string, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - account.settings.avatarColor = value; - return await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - async getDeepLinkRedirectUrl(options?: StorageOptions): Promise { return ( await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())) diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index ef5d9d7721e..86b8dd051cb 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -26,6 +26,7 @@ export const PROVIDERS_DISK = new StateDefinition("providers", "disk"); // Auth export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); +export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" }); export const SSO_DISK = new StateDefinition("ssoLogin", "disk"); export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory"); diff --git a/libs/common/src/services/account/avatar-update.service.ts b/libs/common/src/services/account/avatar-update.service.ts deleted file mode 100644 index d7dee793bdb..00000000000 --- a/libs/common/src/services/account/avatar-update.service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { BehaviorSubject, Observable } from "rxjs"; - -import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "../../abstractions/account/avatar-update.service"; -import { ApiService } from "../../abstractions/api.service"; -import { UpdateAvatarRequest } from "../../models/request/update-avatar.request"; -import { ProfileResponse } from "../../models/response/profile.response"; -import { StateService } from "../../platform/abstractions/state.service"; - -export class AvatarUpdateService implements AvatarUpdateServiceAbstraction { - private _avatarUpdate$ = new BehaviorSubject(null); - avatarUpdate$: Observable = this._avatarUpdate$.asObservable(); - - constructor( - private apiService: ApiService, - private stateService: StateService, - ) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.loadColorFromState(); - } - - loadColorFromState(): Promise { - return this.stateService.getAvatarColor().then((color) => { - this._avatarUpdate$.next(color); - return color; - }); - } - - pushUpdate(color: string | null): Promise { - return this.apiService.putAvatar(new UpdateAvatarRequest(color)).then((response) => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.stateService.setAvatarColor(response.avatarColor); - this._avatarUpdate$.next(response.avatarColor); - }); - } -} diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 1051ee952bc..77a35ccb871 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -32,6 +32,7 @@ import { AppIdMigrator } from "./migrations/33-move-app-id-to-state-providers"; import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-to-state-providers"; import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to-state-providers"; import { VaultSettingsKeyMigrator } from "./migrations/36-move-show-card-and-identity-to-state-provider"; +import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state-providers"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; @@ -41,7 +42,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 2; -export const CURRENT_VERSION = 36; +export const CURRENT_VERSION = 37; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -80,7 +81,8 @@ export function createMigrationBuilder() { .with(AppIdMigrator, 32, 33) .with(DomainSettingsMigrator, 33, 34) .with(MoveThemeToStateProviderMigrator, 34, 35) - .with(VaultSettingsKeyMigrator, 35, CURRENT_VERSION); + .with(VaultSettingsKeyMigrator, 35, 36) + .with(AvatarColorMigrator, 36, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts new file mode 100644 index 00000000000..c87c9f42f0d --- /dev/null +++ b/libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts @@ -0,0 +1,143 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper, runMigrator } from "../migration-helper.spec"; + +import { AvatarColorMigrator } from "./37-move-avatar-color-to-state-providers"; + +function rollbackJSON() { + return { + authenticatedAccounts: ["user-1", "user-2"], + "user_user-1_avatar_avatarColor": "#ff0000", + "user_user-2_avatar_avatarColor": "#cccccc", + "user-1": { + settings: { + extra: "data", + }, + extra: "data", + }, + "user-2": { + settings: { + extra: "data", + }, + extra: "data", + }, + }; +} + +describe("AvatarColorMigrator", () => { + const migrator = new AvatarColorMigrator(36, 37); + + it("should migrate the avatarColor property from the account settings object to a user StorageKey", async () => { + const output = await runMigrator(migrator, { + authenticatedAccounts: ["user-1", "user-2"] as const, + "user-1": { + settings: { + avatarColor: "#ff0000", + extra: "data", + }, + extra: "data", + }, + "user-2": { + settings: { + avatarColor: "#cccccc", + extra: "data", + }, + extra: "data", + }, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user-1", "user-2"], + "user_user-1_avatar_avatarColor": "#ff0000", + "user_user-2_avatar_avatarColor": "#cccccc", + "user-1": { + settings: { + extra: "data", + }, + extra: "data", + }, + "user-2": { + settings: { + extra: "data", + }, + extra: "data", + }, + }); + }); + + it("should handle missing parts", async () => { + const output = await runMigrator(migrator, { + authenticatedAccounts: ["user-1", "user-2"], + global: { + extra: "data", + }, + "user-1": { + extra: "data", + settings: { + extra: "data", + }, + }, + "user-2": null, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user-1", "user-2"], + global: { + extra: "data", + }, + "user-1": { + extra: "data", + settings: { + extra: "data", + }, + }, + "user-2": null, + }); + }); + + describe("rollback", () => { + let helper: MockProxy; + let sut: AvatarColorMigrator; + + const keyDefinitionLike = { + key: "avatarColor", + stateDefinition: { + name: "avatar", + }, + }; + + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 37); + sut = new AvatarColorMigrator(36, 37); + }); + + it("should null out the avatarColor user StorageKey for each account", async () => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledTimes(2); + expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, null); + expect(helper.setToUser).toHaveBeenCalledWith("user-2", keyDefinitionLike, null); + }); + + it("should add the avatarColor property back to the account settings object", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.set).toHaveBeenCalledWith("user-1", { + settings: { + avatarColor: "#ff0000", + extra: "data", + }, + extra: "data", + }); + expect(helper.set).toHaveBeenCalledWith("user-2", { + settings: { + avatarColor: "#cccccc", + extra: "data", + }, + extra: "data", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts b/libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts new file mode 100644 index 00000000000..36173cc909c --- /dev/null +++ b/libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts @@ -0,0 +1,57 @@ +import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountState = { + settings?: { avatarColor?: string }; +}; + +const AVATAR_COLOR_STATE: StateDefinitionLike = { name: "avatar" }; + +const AVATAR_COLOR_KEY: KeyDefinitionLike = { + key: "avatarColor", + stateDefinition: AVATAR_COLOR_STATE, +}; + +export class AvatarColorMigrator extends Migrator<36, 37> { + async migrate(helper: MigrationHelper): Promise { + const legacyAccounts = await helper.getAccounts(); + + await Promise.all( + legacyAccounts.map(async ({ userId, account }) => { + // Move account avatarColor + if (account?.settings?.avatarColor != null) { + await helper.setToUser(userId, AVATAR_COLOR_KEY, account.settings.avatarColor); + + // Delete old account avatarColor property + delete account?.settings?.avatarColor; + await helper.set(userId, account); + } + }), + ); + } + + async rollback(helper: MigrationHelper): Promise { + async function rollbackUser(userId: string, account: ExpectedAccountState) { + let updatedAccount = false; + const userAvatarColor = await helper.getFromUser(userId, AVATAR_COLOR_KEY); + + if (userAvatarColor) { + if (!account) { + account = {}; + } + + updatedAccount = true; + account.settings.avatarColor = userAvatarColor; + await helper.setToUser(userId, AVATAR_COLOR_KEY, null); + } + + if (updatedAccount) { + await helper.set(userId, account); + } + } + + const accounts = await helper.getAccounts(); + + await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account))); + } +} diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index 200acf97f1b..1b46bf43294 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -7,6 +7,7 @@ import { OrganizationData } from "../../../admin-console/models/data/organizatio import { PolicyData } from "../../../admin-console/models/data/policy.data"; import { ProviderData } from "../../../admin-console/models/data/provider.data"; import { PolicyResponse } from "../../../admin-console/models/response/policy.response"; +import { AvatarService } from "../../../auth/abstractions/avatar.service"; import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; @@ -59,6 +60,7 @@ export class SyncService implements SyncServiceAbstraction { private folderApiService: FolderApiServiceAbstraction, private organizationService: InternalOrganizationServiceAbstraction, private sendApiService: SendApiService, + private avatarService: AvatarService, private logoutCallback: (expired: boolean) => Promise, ) {} @@ -309,7 +311,7 @@ export class SyncService implements SyncServiceAbstraction { await this.cryptoService.setPrivateKey(response.privateKey); await this.cryptoService.setProviderKeys(response.providers); await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations); - await this.stateService.setAvatarColor(response.avatarColor); + await this.avatarService.setAvatarColor(response.avatarColor); await this.stateService.setSecurityStamp(response.securityStamp); await this.stateService.setEmailVerified(response.emailVerified); await this.stateService.setHasPremiumPersonally(response.premiumPersonally); From 34fbfaf2eef1105efacf470f0d5a7d2dc5e47147 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Thu, 14 Mar 2024 13:13:27 -0400 Subject: [PATCH 04/41] [PM-6558] onboarding ext check on log in (#8249) * updated browser runtime background to send hasBWInstalled message on login/unlocked --- apps/browser/src/background/runtime.background.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 55be9ba64d1..f422fc85500 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -97,6 +97,10 @@ export default class RuntimeBackground { case "unlocked": { let item: LockedVaultPendingNotificationsData; + if (msg.command === "loggedIn") { + await this.sendBwInstalledMessageToVault(); + } + if (this.lockedVaultPendingNotifications?.length > 0) { item = this.lockedVaultPendingNotifications.pop(); await closeUnlockPopout(); @@ -351,8 +355,6 @@ export default class RuntimeBackground { if (await this.environmentService.hasManagedEnvironment()) { await this.environmentService.setUrlsToManagedEnvironment(); } - - await this.sendBwInstalledMessageToVault(); } this.onInstalledReason = null; From 16bbddf0e7f5923bd657447de15cfc8238fb867f Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Thu, 14 Mar 2024 15:29:37 -0500 Subject: [PATCH 05/41] [PM-6575] Collection of page details might error when getting text content from field sibilings (#8169) --- .../src/autofill/services/collect-autofill-content.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index f623e0f6c98..1de801a2c2c 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -755,6 +755,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte // Prioritize capturing text content from elements rather than nodes. currentElement = currentElement.parentElement || currentElement.parentNode; + if (!currentElement) { + return textContentItems; + } let siblingElement = nodeIsElement(currentElement) ? currentElement.previousElementSibling From 4f8fa57b9dc939528fefd16ee060451133f2e5b5 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Thu, 14 Mar 2024 16:34:07 -0400 Subject: [PATCH 06/41] fix default value for autoCopyTotp (#8287) --- .../common/src/autofill/services/autofill-settings.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/common/src/autofill/services/autofill-settings.service.ts b/libs/common/src/autofill/services/autofill-settings.service.ts index 41452c536ca..49d6dc40de3 100644 --- a/libs/common/src/autofill/services/autofill-settings.service.ts +++ b/libs/common/src/autofill/services/autofill-settings.service.ts @@ -42,7 +42,7 @@ const AUTOFILL_ON_PAGE_LOAD_POLICY_TOAST_HAS_DISPLAYED = new KeyDefinition( ); const AUTO_COPY_TOTP = new KeyDefinition(AUTOFILL_SETTINGS_DISK, "autoCopyTotp", { - deserializer: (value: boolean) => value ?? false, + deserializer: (value: boolean) => value ?? true, }); const INLINE_MENU_VISIBILITY = new KeyDefinition( @@ -144,7 +144,7 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti ); this.autoCopyTotpState = this.stateProvider.getActive(AUTO_COPY_TOTP); - this.autoCopyTotp$ = this.autoCopyTotpState.state$.pipe(map((x) => x ?? false)); + this.autoCopyTotp$ = this.autoCopyTotpState.state$.pipe(map((x) => x ?? true)); this.inlineMenuVisibilityState = this.stateProvider.getGlobal(INLINE_MENU_VISIBILITY); this.inlineMenuVisibility$ = this.inlineMenuVisibilityState.state$.pipe( From 1d76e80afbdc62741667012ae8ef526c7e236ae3 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 14 Mar 2024 16:38:22 -0500 Subject: [PATCH 07/41] Refactor State Providers (#8273) * Delete A Lot Of Code * Fix Tests * Create SingleUserState Provider Once * Update Manual Instantiations * Fix Service Factory * Delete More * Delete Unused `updatePromise` * `postStorageSave` -> `doStorageSave` * Update Comment * Fix jslib-services --- .../browser/src/background/main.background.ts | 3 +- .../active-user-state-provider.factory.ts | 16 +- apps/cli/src/bw.ts | 3 +- apps/desktop/src/main.ts | 13 +- .../src/services/jslib-services.module.ts | 2 +- .../services/environment.service.spec.ts | 12 +- ...default-active-user-state.provider.spec.ts | 12 +- .../default-active-user-state.provider.ts | 44 ++--- .../default-active-user-state.spec.ts | 51 ++++-- .../default-active-user-state.ts | 173 +++--------------- .../implementations/default-global-state.ts | 116 +----------- .../default-single-user-state.ts | 111 ++--------- .../specific-state.provider.spec.ts | 34 ++-- .../state/implementations/state-base.ts | 109 +++++++++++ 14 files changed, 243 insertions(+), 456 deletions(-) create mode 100644 libs/common/src/platform/state/implementations/state-base.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index fd2208fe3c8..e0dd4e57916 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -409,8 +409,7 @@ export default class MainBackground { ); this.activeUserStateProvider = new DefaultActiveUserStateProvider( this.accountService, - storageServiceProvider, - stateEventRegistrarService, + this.singleUserStateProvider, ); this.derivedStateProvider = new BackgroundDerivedStateProvider( this.memoryStorageForStateProviders, diff --git a/apps/browser/src/platform/background/service-factories/active-user-state-provider.factory.ts b/apps/browser/src/platform/background/service-factories/active-user-state-provider.factory.ts index 6dafd2952e0..ff46ca84e8c 100644 --- a/apps/browser/src/platform/background/service-factories/active-user-state-provider.factory.ts +++ b/apps/browser/src/platform/background/service-factories/active-user-state-provider.factory.ts @@ -9,20 +9,15 @@ import { import { CachedServices, FactoryOptions, factory } from "./factory-options"; import { - StateEventRegistrarServiceInitOptions, - stateEventRegistrarServiceFactory, -} from "./state-event-registrar-service.factory"; -import { - StorageServiceProviderInitOptions, - storageServiceProviderFactory, -} from "./storage-service-provider.factory"; + SingleUserStateProviderInitOptions, + singleUserStateProviderFactory, +} from "./single-user-state-provider.factory"; type ActiveUserStateProviderFactory = FactoryOptions; export type ActiveUserStateProviderInitOptions = ActiveUserStateProviderFactory & AccountServiceInitOptions & - StorageServiceProviderInitOptions & - StateEventRegistrarServiceInitOptions; + SingleUserStateProviderInitOptions; export async function activeUserStateProviderFactory( cache: { activeUserStateProvider?: ActiveUserStateProvider } & CachedServices, @@ -35,8 +30,7 @@ export async function activeUserStateProviderFactory( async () => new DefaultActiveUserStateProvider( await accountServiceFactory(cache, opts), - await storageServiceProviderFactory(cache, opts), - await stateEventRegistrarServiceFactory(cache, opts), + await singleUserStateProviderFactory(cache, opts), ), ); } diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 1e624de1b15..f312c0c37e4 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -294,8 +294,7 @@ export class Main { this.activeUserStateProvider = new DefaultActiveUserStateProvider( this.accountService, - storageServiceProvider, - stateEventRegistrarService, + this.singleUserStateProvider, ); this.derivedStateProvider = new DefaultDerivedStateProvider( diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e2c8f9c0ad3..80dfa04c274 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -124,13 +124,14 @@ export class Main { storageServiceProvider, ); + const singleUserStateProvider = new DefaultSingleUserStateProvider( + storageServiceProvider, + stateEventRegistrarService, + ); + const stateProvider = new DefaultStateProvider( - new DefaultActiveUserStateProvider( - accountService, - storageServiceProvider, - stateEventRegistrarService, - ), - new DefaultSingleUserStateProvider(storageServiceProvider, stateEventRegistrarService), + new DefaultActiveUserStateProvider(accountService, singleUserStateProvider), + singleUserStateProvider, globalStateProvider, new DefaultDerivedStateProvider(this.memoryStorageForStateProviders), ); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 7aecdccbea3..c5ab77e77b5 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -954,7 +954,7 @@ const typesafeProviders: Array = [ safeProvider({ provide: ActiveUserStateProvider, useClass: DefaultActiveUserStateProvider, - deps: [AccountServiceAbstraction, StorageServiceProvider, StateEventRegistrarService], + deps: [AccountServiceAbstraction, SingleUserStateProvider], }), safeProvider({ provide: SingleUserStateProvider, diff --git a/libs/common/src/platform/services/environment.service.spec.ts b/libs/common/src/platform/services/environment.service.spec.ts index c5548959945..1454ada7240 100644 --- a/libs/common/src/platform/services/environment.service.spec.ts +++ b/libs/common/src/platform/services/environment.service.spec.ts @@ -45,13 +45,13 @@ describe("EnvironmentService", () => { storageServiceProvider = new StorageServiceProvider(diskStorageService, memoryStorageService); accountService = mockAccountServiceWith(undefined); + const singleUserStateProvider = new DefaultSingleUserStateProvider( + storageServiceProvider, + stateEventRegistrarService, + ); stateProvider = new DefaultStateProvider( - new DefaultActiveUserStateProvider( - accountService, - storageServiceProvider, - stateEventRegistrarService, - ), - new DefaultSingleUserStateProvider(storageServiceProvider, stateEventRegistrarService), + new DefaultActiveUserStateProvider(accountService, singleUserStateProvider), + singleUserStateProvider, new DefaultGlobalStateProvider(storageServiceProvider), new DefaultDerivedStateProvider(memoryStorageService), ); diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.provider.spec.ts b/libs/common/src/platform/state/implementations/default-active-user-state.provider.spec.ts index 02300185492..c1cc15a176f 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.provider.spec.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.provider.spec.ts @@ -3,14 +3,12 @@ import { mock } from "jest-mock-extended"; import { mockAccountServiceWith, trackEmissions } from "../../../../spec"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { UserId } from "../../../types/guid"; -import { StorageServiceProvider } from "../../services/storage-service.provider"; -import { StateEventRegistrarService } from "../state-event-registrar.service"; +import { SingleUserStateProvider } from "../user-state.provider"; import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider"; describe("DefaultActiveUserStateProvider", () => { - const storageServiceProvider = mock(); - const stateEventRegistrarService = mock(); + const singleUserStateProvider = mock(); const userId = "userId" as UserId; const accountInfo = { id: userId, @@ -22,11 +20,7 @@ describe("DefaultActiveUserStateProvider", () => { let sut: DefaultActiveUserStateProvider; beforeEach(() => { - sut = new DefaultActiveUserStateProvider( - accountService, - storageServiceProvider, - stateEventRegistrarService, - ); + sut = new DefaultActiveUserStateProvider(accountService, singleUserStateProvider); }); afterEach(() => { diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts b/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts index 268b22e5197..3c12477b798 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts @@ -1,56 +1,40 @@ -import { Observable, map } from "rxjs"; +import { Observable, distinctUntilChanged, map } from "rxjs"; import { AccountService } from "../../../auth/abstractions/account.service"; import { UserId } from "../../../types/guid"; -import { StorageServiceProvider } from "../../services/storage-service.provider"; import { KeyDefinition } from "../key-definition"; -import { StateEventRegistrarService } from "../state-event-registrar.service"; import { UserKeyDefinition, isUserKeyDefinition } from "../user-key-definition"; import { ActiveUserState } from "../user-state"; -import { ActiveUserStateProvider } from "../user-state.provider"; +import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider"; import { DefaultActiveUserState } from "./default-active-user-state"; export class DefaultActiveUserStateProvider implements ActiveUserStateProvider { - private cache: Record> = {}; - activeUserId$: Observable; constructor( private readonly accountService: AccountService, - private readonly storageServiceProvider: StorageServiceProvider, - private readonly stateEventRegistrarService: StateEventRegistrarService, + private readonly singleUserStateProvider: SingleUserStateProvider, ) { - this.activeUserId$ = this.accountService.activeAccount$.pipe(map((account) => account?.id)); + this.activeUserId$ = this.accountService.activeAccount$.pipe( + map((account) => account?.id), + // To avoid going to storage when we don't need to, only get updates when there is a true change. + distinctUntilChanged((a, b) => (a == null || b == null ? a == b : a === b)), // Treat null and undefined as equal + ); } get(keyDefinition: KeyDefinition | UserKeyDefinition): ActiveUserState { if (!isUserKeyDefinition(keyDefinition)) { keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(keyDefinition); } - const [location, storageService] = this.storageServiceProvider.get( - keyDefinition.stateDefinition.defaultStorageLocation, - keyDefinition.stateDefinition.storageLocationOverrides, - ); - const cacheKey = this.buildCacheKey(location, keyDefinition); - const existingUserState = this.cache[cacheKey]; - if (existingUserState != null) { - // I have to cast out of the unknown generic but this should be safe if rules - // around domain token are made - return existingUserState as ActiveUserState; - } - const newUserState = new DefaultActiveUserState( + // All other providers cache the creation of their corresponding `State` objects, this instance + // doesn't need to do that since it calls `SingleUserStateProvider` it will go through their caching + // layer, because of that, the creation of this instance is quite simple and not worth caching. + return new DefaultActiveUserState( keyDefinition, - this.accountService, - storageService, - this.stateEventRegistrarService, + this.activeUserId$, + this.singleUserStateProvider, ); - this.cache[cacheKey] = newUserState; - return newUserState; - } - - private buildCacheKey(location: string, keyDefinition: UserKeyDefinition) { - return `${location}_${keyDefinition.fullName}`; } } diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts b/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts index feb9530987b..6e01b615d7b 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts @@ -3,19 +3,21 @@ * @jest-environment ../shared/test.environment.ts */ import { any, mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs"; +import { BehaviorSubject, firstValueFrom, map, of, timeout } from "rxjs"; import { Jsonify } from "type-fest"; import { awaitAsync, trackEmissions } from "../../../../spec"; import { FakeStorageService } from "../../../../spec/fake-storage.service"; -import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; +import { AccountInfo } from "../../../auth/abstractions/account.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { UserId } from "../../../types/guid"; +import { StorageServiceProvider } from "../../services/storage-service.provider"; import { StateDefinition } from "../state-definition"; import { StateEventRegistrarService } from "../state-event-registrar.service"; import { UserKeyDefinition } from "../user-key-definition"; import { DefaultActiveUserState } from "./default-active-user-state"; +import { DefaultSingleUserStateProvider } from "./default-single-user-state.provider"; class TestState { date: Date; @@ -41,23 +43,35 @@ const testKeyDefinition = new UserKeyDefinition(testStateDefinition, }); describe("DefaultActiveUserState", () => { - const accountService = mock(); let diskStorageService: FakeStorageService; + const storageServiceProvider = mock(); const stateEventRegistrarService = mock(); let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>; + + let singleUserStateProvider: DefaultSingleUserStateProvider; + let userState: DefaultActiveUserState; beforeEach(() => { - activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined); - accountService.activeAccount$ = activeAccountSubject; - diskStorageService = new FakeStorageService(); - userState = new DefaultActiveUserState( - testKeyDefinition, - accountService, - diskStorageService, + storageServiceProvider.get.mockReturnValue(["disk", diskStorageService]); + + singleUserStateProvider = new DefaultSingleUserStateProvider( + storageServiceProvider, stateEventRegistrarService, ); + + activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined); + + userState = new DefaultActiveUserState( + testKeyDefinition, + activeAccountSubject.asObservable().pipe(map((a) => a?.id)), + singleUserStateProvider, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); }); const makeUserId = (id: string) => { @@ -223,7 +237,16 @@ describe("DefaultActiveUserState", () => { await changeActiveUser("1"); // This should always return a value right await - const value = await firstValueFrom(userState.state$); + const value = await firstValueFrom( + userState.state$.pipe( + timeout({ + first: 20, + with: () => { + throw new Error("Did not emit data from newly active user."); + }, + }), + ), + ); expect(value).toEqual(user1Data); // Make it such that there is no active user @@ -392,9 +415,7 @@ describe("DefaultActiveUserState", () => { await changeActiveUser(undefined); // Act - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - expect(async () => await userState.update(() => null)).rejects.toThrow( + await expect(async () => await userState.update(() => null)).rejects.toThrow( "No active user at this time.", ); }); @@ -563,7 +584,7 @@ describe("DefaultActiveUserState", () => { }); it("does not await updates if the active user changes", async () => { - const initialUserId = (await firstValueFrom(accountService.activeAccount$)).id; + const initialUserId = (await firstValueFrom(activeAccountSubject)).id; expect(initialUserId).toBe(userId); trackEmissions(userState.state$); await awaitAsync(); // storage updates are behind a promise diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.ts b/libs/common/src/platform/state/implementations/default-active-user-state.ts index ae656d836e2..ee90204b617 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.ts @@ -1,118 +1,27 @@ -import { - Observable, - map, - switchMap, - firstValueFrom, - filter, - timeout, - merge, - share, - ReplaySubject, - timer, - tap, - throwError, - distinctUntilChanged, - withLatestFrom, -} from "rxjs"; +import { Observable, map, switchMap, firstValueFrom, timeout, throwError, NEVER } from "rxjs"; -import { AccountService } from "../../../auth/abstractions/account.service"; import { UserId } from "../../../types/guid"; -import { - AbstractStorageService, - ObservableStorageService, -} from "../../abstractions/storage.service"; -import { StateEventRegistrarService } from "../state-event-registrar.service"; -import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; +import { StateUpdateOptions } from "../state-update-options"; import { UserKeyDefinition } from "../user-key-definition"; import { ActiveUserState, CombinedState, activeMarker } from "../user-state"; - -import { getStoredValue } from "./util"; - -const FAKE = Symbol("fake"); +import { SingleUserStateProvider } from "../user-state.provider"; export class DefaultActiveUserState implements ActiveUserState { [activeMarker]: true; - private updatePromise: Promise<[UserId, T]> | null = null; - - private activeUserId$: Observable; - combinedState$: Observable>; state$: Observable; constructor( protected keyDefinition: UserKeyDefinition, - private accountService: AccountService, - private chosenStorageLocation: AbstractStorageService & ObservableStorageService, - private stateEventRegistrarService: StateEventRegistrarService, + private activeUserId$: Observable, + private singleUserStateProvider: SingleUserStateProvider, ) { - this.activeUserId$ = this.accountService.activeAccount$.pipe( - // We only care about the UserId but we do want to know about no user as well. - map((a) => a?.id), - // To avoid going to storage when we don't need to, only get updates when there is a true change. - distinctUntilChanged((a, b) => (a == null || b == null ? a == b : a === b)), // Treat null and undefined as equal - ); - - const userChangeAndInitial$ = this.activeUserId$.pipe( - // If the user has changed, we no longer need to lock an update call - // since that call will be for a user that is no longer active. - tap(() => (this.updatePromise = null)), - switchMap(async (userId) => { - // We've switched or started off with no active user. So, - // emit a fake value so that we can fill our share buffer. - if (userId == null) { - return FAKE; - } - - const fullKey = this.keyDefinition.buildKey(userId); - const data = await getStoredValue( - fullKey, - this.chosenStorageLocation, - this.keyDefinition.deserializer, - ); - return [userId, data] as CombinedState; - }), - ); - - const latestStorage$ = this.chosenStorageLocation.updates$.pipe( - // Use withLatestFrom so that we do NOT emit when activeUserId changes because that - // is taken care of above, but we do want to have the latest user id - // when we get a storage update so we can filter the full key - withLatestFrom( - this.activeUserId$.pipe( - // Null userId is already taken care of through the userChange observable above - filter((u) => u != null), - // Take the userId and build the fullKey that we can now create - map((userId) => [userId, this.keyDefinition.buildKey(userId)] as const), - ), + this.combinedState$ = this.activeUserId$.pipe( + switchMap((userId) => + userId != null + ? this.singleUserStateProvider.get(userId, this.keyDefinition).combinedState$ + : NEVER, ), - // Filter to only storage updates that pertain to our key - filter(([storageUpdate, [_userId, fullKey]]) => storageUpdate.key === fullKey), - switchMap(async ([storageUpdate, [userId, fullKey]]) => { - // We can shortcut on updateType of "remove" - // and just emit null. - if (storageUpdate.updateType === "remove") { - return [userId, null] as CombinedState; - } - - return [ - userId, - await getStoredValue( - fullKey, - this.chosenStorageLocation, - this.keyDefinition.deserializer, - ), - ] as CombinedState; - }), - ); - - this.combinedState$ = merge(userChangeAndInitial$, latestStorage$).pipe( - share({ - connector: () => new ReplaySubject | typeof FAKE>(1), - resetOnRefCountZero: () => timer(this.keyDefinition.cleanupDelayMs), - }), - // Filter out FAKE AFTER the share so that we can fill the ReplaySubjects - // buffer with something and avoid emitting when there is no active user. - filter>((d) => d !== (FAKE as unknown)), ); // State should just be combined state without the user id @@ -123,52 +32,17 @@ export class DefaultActiveUserState implements ActiveUserState { configureState: (state: T, dependency: TCombine) => T, options: StateUpdateOptions = {}, ): Promise<[UserId, T]> { - options = populateOptionsWithDefault(options); - try { - if (this.updatePromise != null) { - await this.updatePromise; - } - this.updatePromise = this.internalUpdate(configureState, options); - const [userId, newState] = await this.updatePromise; - return [userId, newState]; - } finally { - this.updatePromise = null; - } - } - - private async internalUpdate( - configureState: (state: T, dependency: TCombine) => T, - options: StateUpdateOptions, - ): Promise<[UserId, T]> { - const [userId, key, currentState] = await this.getStateForUpdate(); - const combinedDependencies = - options.combineLatestWith != null - ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) - : null; - - if (!options.shouldUpdate(currentState, combinedDependencies)) { - return [userId, currentState]; - } - - const newState = configureState(currentState, combinedDependencies); - await this.saveToStorage(key, newState); - if (newState != null && currentState == null) { - // Only register this state as something clearable on the first time it saves something - // worth deleting. This is helpful in making sure there is less of a race to adding events. - await this.stateEventRegistrarService.registerEvents(this.keyDefinition); - } - return [userId, newState]; - } - - /** For use in update methods, does not wait for update to complete before yielding state. - * The expectation is that that await is already done - */ - protected async getStateForUpdate() { const userId = await firstValueFrom( this.activeUserId$.pipe( timeout({ first: 1000, - with: () => throwError(() => new Error("Timeout while retrieving active user.")), + with: () => + throwError( + () => + new Error( + `Timeout while retrieving active user for key ${this.keyDefinition.fullName}.`, + ), + ), }), ), ); @@ -177,15 +51,12 @@ export class DefaultActiveUserState implements ActiveUserState { `Error storing ${this.keyDefinition.fullName} for the active user: No active user at this time.`, ); } - const fullKey = this.keyDefinition.buildKey(userId); + return [ userId, - fullKey, - await getStoredValue(fullKey, this.chosenStorageLocation, this.keyDefinition.deserializer), - ] as const; - } - - protected saveToStorage(key: string, data: T): Promise { - return this.chosenStorageLocation.save(key, data); + await this.singleUserStateProvider + .get(userId, this.keyDefinition) + .update(configureState, options), + ]; } } diff --git a/libs/common/src/platform/state/implementations/default-global-state.ts b/libs/common/src/platform/state/implementations/default-global-state.ts index db296194077..f44d5e26c6e 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.ts @@ -1,120 +1,20 @@ -import { - Observable, - ReplaySubject, - defer, - filter, - firstValueFrom, - merge, - share, - switchMap, - timeout, - timer, -} from "rxjs"; - import { AbstractStorageService, ObservableStorageService, } from "../../abstractions/storage.service"; import { GlobalState } from "../global-state"; import { KeyDefinition, globalKeyBuilder } from "../key-definition"; -import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; -import { getStoredValue } from "./util"; - -export class DefaultGlobalState implements GlobalState { - private storageKey: string; - private updatePromise: Promise | null = null; - - readonly state$: Observable; +import { StateBase } from "./state-base"; +export class DefaultGlobalState + extends StateBase> + implements GlobalState +{ constructor( - private keyDefinition: KeyDefinition, - private chosenLocation: AbstractStorageService & ObservableStorageService, + keyDefinition: KeyDefinition, + chosenLocation: AbstractStorageService & ObservableStorageService, ) { - this.storageKey = globalKeyBuilder(this.keyDefinition); - const initialStorageGet$ = defer(() => { - return getStoredValue(this.storageKey, this.chosenLocation, this.keyDefinition.deserializer); - }); - - const latestStorage$ = this.chosenLocation.updates$.pipe( - filter((s) => s.key === this.storageKey), - switchMap(async (storageUpdate) => { - if (storageUpdate.updateType === "remove") { - return null; - } - - return await getStoredValue( - this.storageKey, - this.chosenLocation, - this.keyDefinition.deserializer, - ); - }), - ); - - this.state$ = merge(initialStorageGet$, latestStorage$).pipe( - share({ - connector: () => new ReplaySubject(1), - resetOnRefCountZero: () => timer(this.keyDefinition.cleanupDelayMs), - }), - ); - } - - async update( - configureState: (state: T, dependency: TCombine) => T, - options: StateUpdateOptions = {}, - ): Promise { - options = populateOptionsWithDefault(options); - if (this.updatePromise != null) { - await this.updatePromise; - } - - try { - this.updatePromise = this.internalUpdate(configureState, options); - const newState = await this.updatePromise; - return newState; - } finally { - this.updatePromise = null; - } - } - - private async internalUpdate( - configureState: (state: T, dependency: TCombine) => T, - options: StateUpdateOptions, - ): Promise { - const currentState = await this.getStateForUpdate(); - const combinedDependencies = - options.combineLatestWith != null - ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) - : null; - - if (!options.shouldUpdate(currentState, combinedDependencies)) { - return currentState; - } - - const newState = configureState(currentState, combinedDependencies); - await this.chosenLocation.save(this.storageKey, newState); - return newState; - } - - /** For use in update methods, does not wait for update to complete before yielding state. - * The expectation is that that await is already done - */ - private async getStateForUpdate() { - return await getStoredValue( - this.storageKey, - this.chosenLocation, - this.keyDefinition.deserializer, - ); - } - - async getFromState(): Promise { - if (this.updatePromise != null) { - return await this.updatePromise; - } - return await getStoredValue( - this.storageKey, - this.chosenLocation, - this.keyDefinition.deserializer, - ); + super(globalKeyBuilder(keyDefinition), chosenLocation, keyDefinition); } } diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.ts b/libs/common/src/platform/state/implementations/default-single-user-state.ts index b01e0958b5c..fc25e0afbc5 100644 --- a/libs/common/src/platform/state/implementations/default-single-user-state.ts +++ b/libs/common/src/platform/state/implementations/default-single-user-state.ts @@ -1,17 +1,4 @@ -import { - Observable, - ReplaySubject, - combineLatest, - defer, - filter, - firstValueFrom, - merge, - of, - share, - switchMap, - timeout, - timer, -} from "rxjs"; +import { Observable, combineLatest, of } from "rxjs"; import { UserId } from "../../../types/guid"; import { @@ -19,105 +6,31 @@ import { ObservableStorageService, } from "../../abstractions/storage.service"; import { StateEventRegistrarService } from "../state-event-registrar.service"; -import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; import { UserKeyDefinition } from "../user-key-definition"; import { CombinedState, SingleUserState } from "../user-state"; -import { getStoredValue } from "./util"; +import { StateBase } from "./state-base"; -export class DefaultSingleUserState implements SingleUserState { - private storageKey: string; - private updatePromise: Promise | null = null; - - readonly state$: Observable; +export class DefaultSingleUserState + extends StateBase> + implements SingleUserState +{ readonly combinedState$: Observable>; constructor( readonly userId: UserId, - private keyDefinition: UserKeyDefinition, - private chosenLocation: AbstractStorageService & ObservableStorageService, + keyDefinition: UserKeyDefinition, + chosenLocation: AbstractStorageService & ObservableStorageService, private stateEventRegistrarService: StateEventRegistrarService, ) { - this.storageKey = this.keyDefinition.buildKey(this.userId); - const initialStorageGet$ = defer(() => { - return getStoredValue(this.storageKey, this.chosenLocation, this.keyDefinition.deserializer); - }); - - const latestStorage$ = chosenLocation.updates$.pipe( - filter((s) => s.key === this.storageKey), - switchMap(async (storageUpdate) => { - if (storageUpdate.updateType === "remove") { - return null; - } - - return await getStoredValue( - this.storageKey, - this.chosenLocation, - this.keyDefinition.deserializer, - ); - }), - ); - - this.state$ = merge(initialStorageGet$, latestStorage$).pipe( - share({ - connector: () => new ReplaySubject(1), - resetOnRefCountZero: () => timer(this.keyDefinition.cleanupDelayMs), - }), - ); - + super(keyDefinition.buildKey(userId), chosenLocation, keyDefinition); this.combinedState$ = combineLatest([of(userId), this.state$]); } - async update( - configureState: (state: T, dependency: TCombine) => T, - options: StateUpdateOptions = {}, - ): Promise { - options = populateOptionsWithDefault(options); - if (this.updatePromise != null) { - await this.updatePromise; - } - - try { - this.updatePromise = this.internalUpdate(configureState, options); - const newState = await this.updatePromise; - return newState; - } finally { - this.updatePromise = null; - } - } - - private async internalUpdate( - configureState: (state: T, dependency: TCombine) => T, - options: StateUpdateOptions, - ): Promise { - const currentState = await this.getStateForUpdate(); - const combinedDependencies = - options.combineLatestWith != null - ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) - : null; - - if (!options.shouldUpdate(currentState, combinedDependencies)) { - return currentState; - } - - const newState = configureState(currentState, combinedDependencies); - await this.chosenLocation.save(this.storageKey, newState); - if (newState != null && currentState == null) { - // Only register this state as something clearable on the first time it saves something - // worth deleting. This is helpful in making sure there is less of a race to adding events. + protected override async doStorageSave(newState: T, oldState: T): Promise { + await super.doStorageSave(newState, oldState); + if (newState != null && oldState == null) { await this.stateEventRegistrarService.registerEvents(this.keyDefinition); } - return newState; - } - - /** For use in update methods, does not wait for update to complete before yielding state. - * The expectation is that that await is already done - */ - private async getStateForUpdate() { - return await getStoredValue( - this.storageKey, - this.chosenLocation, - this.keyDefinition.deserializer, - ); } } diff --git a/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts b/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts index f3e5ef7f610..da41908935b 100644 --- a/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts +++ b/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts @@ -34,11 +34,7 @@ describe("Specific State Providers", () => { storageServiceProvider, stateEventRegistrarService, ); - activeSut = new DefaultActiveUserStateProvider( - mockAccountServiceWith(null), - storageServiceProvider, - stateEventRegistrarService, - ); + activeSut = new DefaultActiveUserStateProvider(mockAccountServiceWith(null), singleSut); globalSut = new DefaultGlobalStateProvider(storageServiceProvider); }); @@ -67,21 +63,25 @@ describe("Specific State Providers", () => { }, ); - describe.each([ + const globalAndSingle = [ + { + getMethod: (keyDefinition: KeyDefinition) => globalSut.get(keyDefinition), + expectedInstance: DefaultGlobalState, + }, { // Use a static user id so that it has the same signature as the rest and then write special tests // handling differing user id getMethod: (keyDefinition: KeyDefinition) => singleSut.get(fakeUser1, keyDefinition), expectedInstance: DefaultSingleUserState, }, + ]; + + describe.each([ { getMethod: (keyDefinition: KeyDefinition) => activeSut.get(keyDefinition), expectedInstance: DefaultActiveUserState, }, - { - getMethod: (keyDefinition: KeyDefinition) => globalSut.get(keyDefinition), - expectedInstance: DefaultGlobalState, - }, + ...globalAndSingle, ])("common behavior %s", ({ getMethod, expectedInstance }) => { it("returns expected instance", () => { const state = getMethod(fakeDiskKeyDefinition); @@ -90,12 +90,6 @@ describe("Specific State Providers", () => { expect(state).toBeInstanceOf(expectedInstance); }); - it("returns cached instance on repeated request", () => { - const stateFirst = getMethod(fakeDiskKeyDefinition); - const stateCached = getMethod(fakeDiskKeyDefinition); - expect(stateFirst).toStrictEqual(stateCached); - }); - it("returns different instances when the storage location differs", () => { const stateDisk = getMethod(fakeDiskKeyDefinition); const stateMemory = getMethod(fakeMemoryKeyDefinition); @@ -115,6 +109,14 @@ describe("Specific State Providers", () => { }); }); + describe.each(globalAndSingle)("Global And Single Behavior", ({ getMethod }) => { + it("returns cached instance on repeated request", () => { + const stateFirst = getMethod(fakeDiskKeyDefinition); + const stateCached = getMethod(fakeDiskKeyDefinition); + expect(stateFirst).toStrictEqual(stateCached); + }); + }); + describe("DefaultSingleUserStateProvider only behavior", () => { const fakeUser2 = "00000000-0000-1000-a000-000000000002" as UserId; diff --git a/libs/common/src/platform/state/implementations/state-base.ts b/libs/common/src/platform/state/implementations/state-base.ts new file mode 100644 index 00000000000..c09771033cc --- /dev/null +++ b/libs/common/src/platform/state/implementations/state-base.ts @@ -0,0 +1,109 @@ +import { + Observable, + ReplaySubject, + defer, + filter, + firstValueFrom, + merge, + share, + switchMap, + timeout, + timer, +} from "rxjs"; +import { Jsonify } from "type-fest"; + +import { StorageKey } from "../../../types/state"; +import { + AbstractStorageService, + ObservableStorageService, +} from "../../abstractions/storage.service"; +import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; + +import { getStoredValue } from "./util"; + +// The parts of a KeyDefinition this class cares about to make it work +type KeyDefinitionRequirements = { + deserializer: (jsonState: Jsonify) => T; + cleanupDelayMs: number; +}; + +export abstract class StateBase> { + private updatePromise: Promise; + + readonly state$: Observable; + + constructor( + protected readonly key: StorageKey, + protected readonly storageService: AbstractStorageService & ObservableStorageService, + protected readonly keyDefinition: KeyDef, + ) { + const storageUpdate$ = storageService.updates$.pipe( + filter((storageUpdate) => storageUpdate.key === key), + switchMap(async (storageUpdate) => { + if (storageUpdate.updateType === "remove") { + return null; + } + + return await getStoredValue(key, storageService, keyDefinition.deserializer); + }), + ); + + this.state$ = merge( + defer(() => getStoredValue(key, storageService, keyDefinition.deserializer)), + storageUpdate$, + ).pipe( + share({ + connector: () => new ReplaySubject(1), + resetOnRefCountZero: () => timer(keyDefinition.cleanupDelayMs), + }), + ); + } + + async update( + configureState: (state: T, dependency: TCombine) => T, + options: StateUpdateOptions = {}, + ): Promise { + options = populateOptionsWithDefault(options); + if (this.updatePromise != null) { + await this.updatePromise; + } + + try { + this.updatePromise = this.internalUpdate(configureState, options); + const newState = await this.updatePromise; + return newState; + } finally { + this.updatePromise = null; + } + } + + private async internalUpdate( + configureState: (state: T, dependency: TCombine) => T, + options: StateUpdateOptions, + ): Promise { + const currentState = await this.getStateForUpdate(); + const combinedDependencies = + options.combineLatestWith != null + ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) + : null; + + if (!options.shouldUpdate(currentState, combinedDependencies)) { + return currentState; + } + + const newState = configureState(currentState, combinedDependencies); + await this.doStorageSave(newState, currentState); + return newState; + } + + protected async doStorageSave(newState: T, oldState: T) { + await this.storageService.save(this.key, newState); + } + + /** For use in update methods, does not wait for update to complete before yielding state. + * The expectation is that that await is already done + */ + private async getStateForUpdate() { + return await getStoredValue(this.key, this.storageService, this.keyDefinition.deserializer); + } +} From 550684262300c200d6bcaf4ff958e5955f060c9b Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Fri, 15 Mar 2024 09:33:15 +1000 Subject: [PATCH 08/41] [AC-2171] Member modal - limit admin access - editing self (#8299) * If editing your own member modal, you cannot add new collections or groups * Update forms to prevent this * Add helper text * Delete unused api method --- .../member-dialog.component.html | 13 ++++- .../member-dialog/member-dialog.component.ts | 48 +++++++++++++++++-- .../access-selector.component.html | 2 +- .../access-selector.component.ts | 5 ++ apps/web/src/locales/en/messages.json | 6 +++ .../organization-user.service.ts | 13 ----- .../organization-user/requests/index.ts | 1 - ...organization-user-update-groups.request.ts | 3 -- ...rganization-user.service.implementation.ts | 15 ------ 9 files changed, 67 insertions(+), 39 deletions(-) delete mode 100644 libs/common/src/admin-console/abstractions/organization-user/requests/organization-user-update-groups.request.ts diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html index 46d8c6a8671..95febbd3c5e 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html @@ -399,7 +399,11 @@
- {{ "groupAccessUserDesc" | i18n }} + {{ + (restrictedAccess$ | async) + ? ("restrictedGroupAccess" | i18n) + : ("groupAccessUserDesc" | i18n) + }}
-
+
+ {{ "restrictedCollectionAccess" | i18n }} +
+
{{ "userPermissionOverrideHelper" | i18n }}
@@ -441,6 +449,7 @@ [selectorLabelText]="'selectCollections' | i18n" [emptySelectionText]="'noCollectionsAdded' | i18n" [flexibleCollectionsEnabled]="organization.flexibleCollections" + [hideMultiSelect]="restrictedAccess$ | async" > diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index 4d6442e8988..38ddf013c08 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -4,6 +4,7 @@ import { FormBuilder, Validators } from "@angular/forms"; import { combineLatest, firstValueFrom, + map, Observable, of, shareReplay, @@ -20,7 +21,9 @@ import { } from "@bitwarden/common/admin-console/enums"; import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -99,6 +102,8 @@ export class MemberDialogComponent implements OnDestroy { groups: [[] as AccessItemValue[]], }); + protected restrictedAccess$: Observable; + protected permissionsGroup = this.formBuilder.group({ manageAssignedCollectionsGroup: this.formBuilder.group>({ manageAssignedCollections: false, @@ -144,6 +149,7 @@ export class MemberDialogComponent implements OnDestroy { private organizationUserService: OrganizationUserService, private dialogService: DialogService, private configService: ConfigServiceAbstraction, + private accountService: AccountService, organizationService: OrganizationService, ) { this.organization$ = organizationService @@ -162,12 +168,42 @@ export class MemberDialogComponent implements OnDestroy { ), ); + const userDetails$ = this.params.organizationUserId + ? this.userService.get(this.params.organizationId, this.params.organizationUserId) + : of(null); + + // The orgUser cannot manage their own Group assignments if collection access is restricted + // TODO: fix disabled state of access-selector rows so that any controls are hidden + this.restrictedAccess$ = combineLatest([ + this.organization$, + userDetails$, + this.accountService.activeAccount$, + this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1), + ]).pipe( + map( + ([organization, userDetails, activeAccount, flexibleCollectionsV1Enabled]) => + // Feature flag conditionals + flexibleCollectionsV1Enabled && + organization.flexibleCollections && + // Business logic conditionals + userDetails.userId == activeAccount.id && + !organization.allowAdminAccessToAllCollectionItems, + ), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.restrictedAccess$.pipe(takeUntil(this.destroy$)).subscribe((restrictedAccess) => { + if (restrictedAccess) { + this.formGroup.controls.groups.disable(); + } else { + this.formGroup.controls.groups.enable(); + } + }); + combineLatest({ organization: this.organization$, collections: this.collectionAdminService.getAll(this.params.organizationId), - userDetails: this.params.organizationUserId - ? this.userService.get(this.params.organizationId, this.params.organizationUserId) - : of(null), + userDetails: userDetails$, groups: groups$, }) .pipe(takeUntil(this.destroy$)) @@ -369,7 +405,11 @@ export class MemberDialogComponent implements OnDestroy { userView.collections = this.formGroup.value.access .filter((v) => v.type === AccessItemType.Collection) .map(convertToSelectionView); - userView.groups = this.formGroup.value.groups.map((m) => m.id); + + userView.groups = (await firstValueFrom(this.restrictedAccess$)) + ? null + : this.formGroup.value.groups.map((m) => m.id); + userView.accessSecretsManager = this.formGroup.value.accessSecretsManager; if (this.editMode) { diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html index 5a22b7b2287..16a24781dfa 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html @@ -1,6 +1,6 @@ -
+
{{ "permission" | i18n }} getAccessToken/setAccessToken/decodeAccessToken * PM-5263 - TokenSvc State Provider Migrator - WIP - update expected acct type to match actual account * PM-5263 - TokenService - clearToken renamed to clearTokens * PM-5263 - CLI - NodeApiService - add state service dep to get CLI building. * PM-5263 - StateDefinitions - use unique state definition names * PM-5263 - StateSvc - remove getTimeoutBasedStorageOptions as no longer used. * PM-5263 - TokenSvc - Add TODO for figuring out how to store tokens in secure storage. * PM-5263 - StateSvc - remove get/set 2FA token - references migrated later. * PM-5263 - TODO: figure out if using same key definition names is an issue * PM-5263 - TokenServiceStateProviderMigrator written * PM-5263 - TokenServiceStateProviderMigrator - (1) Don't update legacy account if we only added a new state in state provider for 2FA token (2) Use for loop for easier debugging * PM-5263 - TokenServiceStateProviderMigrator test - WIP - migration testing mostly complete and passing. Rollback logic TODO. * PM-5263 - TokenServiceStateProviderMigrator - Add rollback logic to restore 2FA token from users to global. * PM-5263 - TokenServiceStateProviderMigrator - Refactor rollback to only set account once as not necessary to set it every time. * PM-5263 - TokenServiceStateProviderMigrator tests - test all rollback scenarios * PM-5263 - Remove TODO as don't need unique key def names as long as state def keys are unique. * PM-5263 - TokenSvc - update clearAccessTokenByUserId to use proper state provider helper method to set state. * PM-5263 - Revert accidentally committing settings.json changes. * PM-5263 - TokenSvc - update all 2FA token methods to require email so we can user specifically scope 2FA tokens while still storing them in global storage. * PM-5263 - Update all token service 2FA set / get / clear methods to pass in email. * PM-5263 - JslibServices module - add missed login service to login strategy svc deps. * PM-5263 - VaultTimeoutSettingsService - setVaultTimeoutOptions - rename token to accesToken for clarity. * PM-5263 - (1) TokenSvc - remove getAccessTokenByUserId and force consumers to use getAccessToken w/ optional user id to keep interface small (2) TokenSvc - attempt to implement secure storage on platforms that support it for access & refresh token storage (3) StateSvc - replace usage of getAccessTokenByUserId with getAccessToken * PM-5263 - TokenSvc - add platform utils and secure storage svc deps * PM-5263 - TODO: figure out what to do with broken migration * PM-5263 - TODO: update tests in light of latest 2FA token changes. * PM-5263 - TokenSvc - clean up TODO * PM-5263 - We should have tests for the token service. * PM-5263 - TokenSvc - setAccessToken - If platform supports secure storage and we are saving an access token, remove the access token from memory and disk to fully migrate to secure storage. * PM-5263 - TokenSvc - getAccessToken - Update logic to look at memory and disk first always and secure storage last to support the secure storage migration * PM-5263 - TokenSvc - setAccesToken - if user id null on a secure storage supporting platform, throw error. * PM-5263 - TokenService - (1) Refresh token now stored in secure storage (2) Refresh token set now private as we require a user id to store it in secure storage and we can use the setTokens method to enforce always setting the access token and refresh token together in order to extract a user id from the refresh token. (3) setTokens clientIdClientSecret param now optional * PM-5263 - TokenServiceStateProviderMigrator - update migration to take global but user scoped 2FA token storage changes into account. * PM-5263 - Remove old migration as it references state we are removing. Bump min version. Co-authored-by: Matt Gibson * PM-5263 - TokenService - 2FA token methods now backed by global state record which maps email to individual tokens. * PM-5263 - WIP on Token Svc migrator and test updates based on new 2FA token storage changes. * PM-5263 - TokenSvc - (1) Add jira tickets to clean up state migration (2) Add state to track secure storage migration to improve # of reads to get data * PM-5263 - StateDef - consolidate name of token domain state defs per feedback from Justin + update migration tests * PM-5263 - TokenSvc - fix error message and add TODO * PM-5263 - Update token service migration + tests to pass after all 2FA token changes. * PM-5263 - Fix all login strategy tests which were failing due to token state provider changes + the addition of the loginService as a dependency in the base login strategy. * PM-5263 - Register TokenService state provider migration with migrator * PM-5263 - TokenSvc state migration - set tokens after initializing account * PM-5263 - TokenService changes - WIP - convert from ActiveUserStateProvider to just SingleUserStateProvider to avoid future circ dependency issues. Co-authored-by: Jake Fink * PM-5263 - TokenSvc - create getSecureStorageOptions for centralizing all logic for getting data out of SecureStorage. * PM-5263 - TokenSvc - (1) Refactor determineStorageLocation to also determine secure storage - created a TokenStorageLocation string enum to remove magic strings (2) Refactor setAccessToken to use switch (3) Refactor clearAccessTokenByUserId to clear all locations and not early return on secure storage b/c we only use secure storage if disk is the location but I don't want to require vault timeout data for this method. * PM-5263 - TokenSvc - getDataFromSecureStorage - Refactor to be more generic for easier re-use * PM-5263 - TokenSvc - Convert refresh token methods to use single user state and require user ids * PM-5263 - VaultTimeoutSettingsSvc - get user id and pass to access and refresh token methods. * PM-5263 - TokenSvc - refactor save secure storage logic into private helper. * PM-5263 - Base Login Strategy - per discussion with Justin, move save of tokens to before account initialization as we can always derive the user id from the access token. This will ensure that the account is initialized with the proper authN status. * PM-5263 - TokenSvc - latest refactor - update all methods to accept optional userId now as we can read active user id from global state provider without using activeUserStateProvider (thus, avoiding a circular dep and having to have every method accept in a mandatory user id). * PM-5263 - VaultTimeoutSettingsService - remove user id from token calls * PM-5263 - TokenSvc - update all places we instantiate token service to properly pass in new deps. * PM-5263 - TokenSvc migration is now 27th instead of 23rd. * PM-5263 - Browser - MainContextMenuHandler - Update service options to include PlatformUtilsServiceInitOptions as the TokenService requires that and the TokenService is now injected on the StateService * PM-5263 - TokenSvc migration test - update rollback tests to start with correct current version * PM-5263 - Create token service test file - WIP * PM-5263 - TokenSvc - tests WIP - instantiates working. * PM-5263 - TokenSvc - set2FAToken - use null coalesce to ensure record is instantiated for new users before setting data on it. * PM-5263 - TokenService tests - WIP - 2FA token tests. * PM-5263 - Worked with Justin to resolve desktop circular dependency issue by adding SUPPORTS_SECURE_STORAGE injection token instead of injecting PlatformUtilsService directly into TokenService. Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * PM-5263 - TokenSvc tests - WIP - (1) Update TokenSvc instantiation to use new supportsSecureStorage (2) Test TwoFactorToken methods * PM-5263 - Fix SUPPORTS_SECURE_STORAGE injection token to properly call supportsSecureStorage message * PM-5263 - Token state testing * PM-5263 - TokenState fix name of describe * PM-5263 - TokenService - export TokenStorageLocation for use in tests. * PM-5263 - TokenSvc Tests WIP * PM-5263 - TokenSvc tests - access token logic mostly completed. * PM-5263 - TokenSvc Tests - more WIP - finish testing access token methods. * PM-5263 - TokenSvc WIP - another clear access token test. * PM-5263 - TokenSvc tests - WIP - SetTokens tested. * PM-5263 - Tweak test name * PM-5263 - TokenSvc tests - remove unnecessary describe around 2FA token methods. * PM-5263 - TokenSvc.clearAccessTokenByUserId renamed to just clearAccessToken * PM-5263 - TokenSvc - refactor clearTokens logic and implement individual clear logic which doesn't require vault timeout setting information. * PM-5263 - TokenSvc - Replace all places we have vaultTimeout: number with vaultTimeout: number | null to be accurate. * PM-5263 - TokenSvc.clearTokens - add check for user id; throw if not found * PM-5263 - TokenService - test clearTokens * PM-5263 - TokenSvc Tests - setRefreshToken tested * PM-5263 - TokenSvc tests - getRefreshToken tested + added a new getAccessToken test * PM-5263 - TokenSvc - ClearRefreshToken scenarios tested. * PM-5263 - TokenSvc.clearRefreshToken tests - fix copy pasta * PM-5263 - TokenSvc tests - (1) Fix mistakes in refresh token testing (2) Test setClientId for all scenarios * PM-5263 - TokenSvc tests - (1) Add some getClientId tests (2) clarify lack of awaits * PM-5263 - TokenSvc Tests - WIP - getClientId && clearClientId * PM-5263 - TokenService - getClientSecret - fix error message * PM-5263 - TokenService tests - test all client secret methods * PM-5263 - Update TokenSvc migration to 30th migration * PM-5263 - TokenService - update all tests to initialize data to undefined now that fake state provider supports faking data based on specific key definitions. * PM-5263 - (1) TokenSvc.decodeAccessToken - update static method's error handling (2) TokenSvc tests - test all decodeAccessToken scenarios * PM-5263 - TokenSvc - (1) Add DecodedAccessToken type (2) Refactor getTokenExpirationDate logic to use new type and make proper type checks for numbers for exp claim values. * PM-5263 - TokenSvc tests - test getTokenExpirationDate method. * PM-5263 - TokenSvc - (1) Update DecodedAccessToken docs (2) Tweak naming in tokenSecondsRemaining * PM-5263 - TokenSvc abstraction - add jsdoc for tokenSecondsRemaining * PM-5263 - TokenSvc tests - test tokenSecondsRemaining * PM-5263 - TokenSvc - DecodedAccessToken type - update sstamp info * PM-5263 - TokenService - fix flaky tokenSecondsRemaining tests by locking time * PM-5263 - TokenSvc Tests - Test tokenNeedsRefresh * PM-5263 - (1) TokenSvc - Refactor getUserId to add extra safety (2) TokenSvc tests - test getUserId * PM-5263 - (1) TokenSvc - refactor getUserIdFromAccessToken to handle decoding errors (2) TokenSvc tests - test getUserIdFromAccessToken * PM-5263 - (1) TokenSvc - Refactor getEmail to handle decoding errors + check for specific, expected type (2) TokenSvc tests - test getEmail * PM-5263 - TokenSvc tests - clean up comment * PM-5263 - (1) TokenSvc - getEmailVerified - refactor (2) TokenSvc tests - add getEmailVerified tests * PM-5263 - (1) TokenSvc - refactor getName (2) TokenSvc tests - test getName * PM-5263 - (1) TokenSvc - refactor getIssuer (2) TokenSvc tests - test getIssuer * PM-5263 - TokenSvc - remove unnecessary "as type" statements now that we have a decoded access token type * PM-5263 - (1) TokenSvc - refactor getIsExternal (2) TokenSvc Tests - test getIsExternal * PM-5263 - TokenSvc abstraction - tune up rest of docs. * PM-5263 - TokenSvc - clean up promise and replace with promise * PM-5263 - TokenSvc abstraction - more docs. * PM-5263 - Clean up TODO as I've tested every method in token svc. * PM-5263 - (1) Extract JWT decode logic into auth owned utility function out of the token service (2) Update TokenService decode logic to use new utility function (3) Update LastPassDirectImportService + vault.ts to use new utility function and remove token service dependency. (4) Update tests + migrate tests to new utility test file. * PM-5263 - Rename decodeJwtTokenToJson to decode-jwt-token-to-json to meet lint rules excluding capitals * PM-5263 - TokenSvc + tests - fix all get methods to return undefined like they did before instead of throwing an error if a user id isn't provided. * PM-5263 - Services.module - add missing token service dep * PM-5263 - Update token svc migrations to be 32nd migration * PM-5263 - Popup - Services.module - Remove token service as it no longer requires a background service due to the migration to state provider. The service definition in jslib-services module is enough. * PM-5263 - BaseLoginStrategy - Extract email out of getTwoFactorToken method call for easier debugging. * PM-5263 - Login Comp - Set email into memory on login service so that base login strategy can access user email for looking up 2FA token stored in global state. * PM-5263 - (1) LoginComp - remove loginSvc.setEmail call as no longer necessary + introduced issues w/ popup and background in browser extension (2) AuthReq & Password login strategies now just pass in email to buildTwoFactor method. * PM-5263 - SsoLoginSvc + abstraction - Add key definition and get/set methods for saving user email in session storage so it persists across the SSO redirect. * PM-5263 - Base Login Strategy - BuildTwoFactor - only try to get 2FA token if we have an email to look up their token * PM-5263 - Remove LoginService dependency from LoginStrategyService * PM-5263 - (1) Save off user email when they click enterprise SSO on all clients in login comp (2) Retrieve it and pass it into login strategy in SSO comp * PM-5263 - (1) TokenSvc - update 2FA token methods to be more safe in case user removes record from local storage (2) Add test cases + missing clearTwoFactorToken tests * PM-5263 - Browser SSO login - save user email for browser SSO process * PM-5263 - Finish removing login service from login strategy tests. * PM-5263 - More removals of the login service from the login strategy tests. * PM-5263 - Main.ts - platformUtilsSvc no longer used in TokenSvc so remove it from desktop main.ts * PM-5263 - Fix failing login strategy service tests * PM-5263 - Bump token svc migration values to migration 35 after merging in main * PM-5263 - Bump token svc migration version * PM-5263 - TokenService.clearTwoFactorToken - use delete instead of setting values to null per discussion with Justin Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * PM-5263 - TokenSvc + decode JWT token tests - anonymize my information Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * PM-5263 - TokenSvc tests - update clear token tests based on actual deletion * PM-5263 - Add docs per PR feedback * PM-5263 - (1) Move ownership of clearing two factor token on rejection from server to base login strategy (2) Each login strategy that supports remember 2FA logic now persists user entered email in its data (3) Base login strategy processTwoFactorResponse now clears 2FA token (4) Updated base login strategy tests to affirm the clearing of the 2FA token * Update libs/auth/src/common/login-strategies/login.strategy.ts Co-authored-by: Jake Fink * Update libs/auth/src/common/login-strategies/password-login.strategy.ts Co-authored-by: Jake Fink * PM-5263 - Login Strategy - per PR feedback, add jsdoc comments to each method I've touched for this PR. * PM-5263 - (1) TokenSvc - adjust setTokens, setAccessToken, setRefreshToken, and clearRefreshToken based on PR feedback to remove optional user ids where possible and improve public interface (2) TokenSvc Abstraction - update docs and abstractions based on removed user ids and changed logic (3) TokenSvc tests - update tests to add new test cases, remove no longer relevant ones, and update test names. * PM-5263 - Bump migrations again --------- Co-authored-by: Matt Gibson Co-authored-by: Jake Fink Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: Jake Fink --- .../token-service.factory.ts | 32 +- .../browser/src/auth/popup/login.component.ts | 2 + .../browser/main-context-menu-handler.ts | 5 + .../browser/src/background/main.background.ts | 26 +- .../service-factories/api-service.factory.ts | 5 +- .../state-service.factory.ts | 6 + .../services/browser-state.service.spec.ts | 4 + .../services/browser-state.service.ts | 3 + .../src/popup/services/services.module.ts | 4 +- apps/cli/src/auth/commands/login.command.ts | 1 + apps/cli/src/bw.ts | 10 +- .../src/platform/services/node-api.service.ts | 3 + .../src/app/services/services.module.ts | 15 +- apps/desktop/src/main.ts | 20 +- .../electron-platform-utils.service.ts | 4 +- apps/web/src/app/core/state/state.service.ts | 3 + .../src/auth/components/login.component.ts | 4 + .../src/auth/components/sso.component.ts | 3 + libs/angular/src/services/injection-tokens.ts | 1 + .../src/services/jslib-services.module.ts | 16 +- libs/auth/src/common/index.ts | 1 + .../auth-request-login.strategy.spec.ts | 2 +- .../auth-request-login.strategy.ts | 2 +- .../login-strategies/login.strategy.spec.ts | 22 +- .../common/login-strategies/login.strategy.ts | 84 +- .../password-login.strategy.spec.ts | 2 +- .../password-login.strategy.ts | 7 +- .../sso-login.strategy.spec.ts | 2 +- .../login-strategies/sso-login.strategy.ts | 9 +- .../user-api-login.strategy.spec.ts | 20 +- .../user-api-login.strategy.ts | 17 +- .../webauthn-login.strategy.spec.ts | 2 +- .../common/models/domain/login-credentials.ts | 5 + .../login-strategy.service.spec.ts | 4 +- .../decode-jwt-token-to-json.utility.spec.ts | 90 + .../decode-jwt-token-to-json.utility.ts | 32 + libs/auth/src/common/utilities/index.ts | 1 + .../sso-login.service.abstraction.ts | 14 + .../src/auth/abstractions/token.service.ts | 213 +- .../src/auth/services/sso-login.service.ts | 20 +- .../src/auth/services/token.service.spec.ts | 2237 +++++++++++++++++ .../common/src/auth/services/token.service.ts | 771 +++++- .../src/auth/services/token.state.spec.ts | 64 + libs/common/src/auth/services/token.state.ts | 65 + .../platform/abstractions/state.service.ts | 11 +- .../src/platform/models/domain/account.ts | 4 - .../src/platform/services/state.service.ts | 96 +- .../src/platform/state/state-definitions.ts | 5 + libs/common/src/services/api.service.ts | 26 +- .../vault-timeout-settings.service.ts | 13 +- libs/common/src/state-migrations/migrate.ts | 10 +- .../migrations/3-fix-premium.spec.ts | 111 - .../migrations/3-fix-premium.ts | 48 - ...igrate-token-svc-to-state-provider.spec.ts | 258 ++ .../38-migrate-token-svc-to-state-provider.ts | 231 ++ .../lastpass-direct-import.service.ts | 4 +- .../src/importers/lastpass/access/vault.ts | 9 +- 57 files changed, 4242 insertions(+), 437 deletions(-) create mode 100644 libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.spec.ts create mode 100644 libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.ts create mode 100644 libs/auth/src/common/utilities/index.ts create mode 100644 libs/common/src/auth/services/token.service.spec.ts create mode 100644 libs/common/src/auth/services/token.state.spec.ts create mode 100644 libs/common/src/auth/services/token.state.ts delete mode 100644 libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts delete mode 100644 libs/common/src/state-migrations/migrations/3-fix-premium.ts create mode 100644 libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts diff --git a/apps/browser/src/auth/background/service-factories/token-service.factory.ts b/apps/browser/src/auth/background/service-factories/token-service.factory.ts index 476b8e2d785..25c30460f06 100644 --- a/apps/browser/src/auth/background/service-factories/token-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/token-service.factory.ts @@ -7,13 +7,29 @@ import { factory, } from "../../../platform/background/service-factories/factory-options"; import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../../platform/background/service-factories/state-service.factory"; + GlobalStateProviderInitOptions, + globalStateProviderFactory, +} from "../../../platform/background/service-factories/global-state-provider.factory"; +import { + PlatformUtilsServiceInitOptions, + platformUtilsServiceFactory, +} from "../../../platform/background/service-factories/platform-utils-service.factory"; +import { + SingleUserStateProviderInitOptions, + singleUserStateProviderFactory, +} from "../../../platform/background/service-factories/single-user-state-provider.factory"; +import { + SecureStorageServiceInitOptions, + secureStorageServiceFactory, +} from "../../../platform/background/service-factories/storage-service.factory"; type TokenServiceFactoryOptions = FactoryOptions; -export type TokenServiceInitOptions = TokenServiceFactoryOptions & StateServiceInitOptions; +export type TokenServiceInitOptions = TokenServiceFactoryOptions & + SingleUserStateProviderInitOptions & + GlobalStateProviderInitOptions & + PlatformUtilsServiceInitOptions & + SecureStorageServiceInitOptions; export function tokenServiceFactory( cache: { tokenService?: AbstractTokenService } & CachedServices, @@ -23,6 +39,12 @@ export function tokenServiceFactory( cache, "tokenService", opts, - async () => new TokenService(await stateServiceFactory(cache, opts)), + async () => + new TokenService( + await singleUserStateProviderFactory(cache, opts), + await globalStateProviderFactory(cache, opts), + (await platformUtilsServiceFactory(cache, opts)).supportsSecureStorage(), + await secureStorageServiceFactory(cache, opts), + ), ); } diff --git a/apps/browser/src/auth/popup/login.component.ts b/apps/browser/src/auth/popup/login.component.ts index 857dae66306..c1dd9526589 100644 --- a/apps/browser/src/auth/popup/login.component.ts +++ b/apps/browser/src/auth/popup/login.component.ts @@ -91,6 +91,8 @@ export class LoginComponent extends BaseLoginComponent { } async launchSsoBrowser() { + // Save off email for SSO + await this.ssoLoginService.setSsoEmail(this.formGroup.value.email); await this.loginService.saveEmailSettings(); // Generate necessary sso params const passwordOptions: any = { diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index 998b5c7258b..b7e26be4a9c 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -184,6 +184,11 @@ export class MainContextMenuHandler { stateServiceOptions: { stateFactory: stateFactory, }, + platformUtilsServiceOptions: { + clipboardWriteCallback: () => Promise.resolve(), + biometricCallback: () => Promise.resolve(false), + win: self, + }, }; return new MainContextMenuHandler( diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 34a5f9e1fd8..23f415fc418 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -427,6 +427,21 @@ export default class MainBackground { ); this.biometricStateService = new DefaultBiometricStateService(this.stateProvider); + this.userNotificationSettingsService = new UserNotificationSettingsService(this.stateProvider); + this.platformUtilsService = new BackgroundPlatformUtilsService( + this.messagingService, + (clipboardValue, clearMs) => this.clearClipboard(clipboardValue, clearMs), + async () => this.biometricUnlock(), + self, + ); + + this.tokenService = new TokenService( + this.singleUserStateProvider, + this.globalStateProvider, + this.platformUtilsService.supportsSecureStorage(), + this.secureStorageService, + ); + const migrationRunner = new MigrationRunner( this.storageService, this.logService, @@ -441,15 +456,9 @@ export default class MainBackground { new StateFactory(GlobalState, Account), this.accountService, this.environmentService, + this.tokenService, migrationRunner, ); - this.userNotificationSettingsService = new UserNotificationSettingsService(this.stateProvider); - this.platformUtilsService = new BackgroundPlatformUtilsService( - this.messagingService, - (clipboardValue, clearMs) => this.clearClipboard(clipboardValue, clearMs), - async () => this.biometricUnlock(), - self, - ); const themeStateService = new DefaultThemeStateService(this.globalStateProvider); @@ -465,13 +474,14 @@ export default class MainBackground { this.stateProvider, this.biometricStateService, ); - this.tokenService = new TokenService(this.stateService); + this.appIdService = new AppIdService(this.globalStateProvider); this.apiService = new ApiService( this.tokenService, this.platformUtilsService, this.environmentService, this.appIdService, + this.stateService, (expired: boolean) => this.logout(expired), ); this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); diff --git a/apps/browser/src/platform/background/service-factories/api-service.factory.ts b/apps/browser/src/platform/background/service-factories/api-service.factory.ts index 649fe1f7fe6..57cd5004413 100644 --- a/apps/browser/src/platform/background/service-factories/api-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/api-service.factory.ts @@ -20,6 +20,7 @@ import { PlatformUtilsServiceInitOptions, platformUtilsServiceFactory, } from "./platform-utils-service.factory"; +import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; type ApiServiceFactoryOptions = FactoryOptions & { apiServiceOptions: { @@ -32,7 +33,8 @@ export type ApiServiceInitOptions = ApiServiceFactoryOptions & TokenServiceInitOptions & PlatformUtilsServiceInitOptions & EnvironmentServiceInitOptions & - AppIdServiceInitOptions; + AppIdServiceInitOptions & + StateServiceInitOptions; export function apiServiceFactory( cache: { apiService?: AbstractApiService } & CachedServices, @@ -48,6 +50,7 @@ export function apiServiceFactory( await platformUtilsServiceFactory(cache, opts), await environmentServiceFactory(cache, opts), await appIdServiceFactory(cache, opts), + await stateServiceFactory(cache, opts), opts.apiServiceOptions.logoutCallback, opts.apiServiceOptions.customUserAgent, ), diff --git a/apps/browser/src/platform/background/service-factories/state-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-service.factory.ts index 8bcb65a3209..20a9ac074a7 100644 --- a/apps/browser/src/platform/background/service-factories/state-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/state-service.factory.ts @@ -5,6 +5,10 @@ import { accountServiceFactory, AccountServiceInitOptions, } from "../../../auth/background/service-factories/account-service.factory"; +import { + tokenServiceFactory, + TokenServiceInitOptions, +} from "../../../auth/background/service-factories/token-service.factory"; import { Account } from "../../../models/account"; import { BrowserStateService } from "../../services/browser-state.service"; @@ -38,6 +42,7 @@ export type StateServiceInitOptions = StateServiceFactoryOptions & LogServiceInitOptions & AccountServiceInitOptions & EnvironmentServiceInitOptions & + TokenServiceInitOptions & MigrationRunnerInitOptions; export async function stateServiceFactory( @@ -57,6 +62,7 @@ export async function stateServiceFactory( opts.stateServiceOptions.stateFactory, await accountServiceFactory(cache, opts), await environmentServiceFactory(cache, opts), + await tokenServiceFactory(cache, opts), await migrationRunnerFactory(cache, opts), opts.stateServiceOptions.useAccountCache, ), diff --git a/apps/browser/src/platform/services/browser-state.service.spec.ts b/apps/browser/src/platform/services/browser-state.service.spec.ts index b5d1b9c38a0..3069b8f1749 100644 --- a/apps/browser/src/platform/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -1,5 +1,6 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { @@ -32,6 +33,7 @@ describe("Browser State Service", () => { let stateFactory: MockProxy>; let useAccountCache: boolean; let environmentService: MockProxy; + let tokenService: MockProxy; let migrationRunner: MockProxy; let state: State; @@ -46,6 +48,7 @@ describe("Browser State Service", () => { logService = mock(); stateFactory = mock(); environmentService = mock(); + tokenService = mock(); migrationRunner = mock(); // turn off account cache for tests useAccountCache = false; @@ -77,6 +80,7 @@ describe("Browser State Service", () => { stateFactory, accountService, environmentService, + tokenService, migrationRunner, useAccountCache, ); diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts index c544915e26c..f7ee74be217 100644 --- a/apps/browser/src/platform/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/browser-state.service.ts @@ -1,6 +1,7 @@ import { BehaviorSubject } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { @@ -45,6 +46,7 @@ export class BrowserStateService stateFactory: StateFactory, accountService: AccountService, environmentService: EnvironmentService, + tokenService: TokenService, migrationRunner: MigrationRunner, useAccountCache = true, ) { @@ -56,6 +58,7 @@ export class BrowserStateService stateFactory, accountService, environmentService, + tokenService, migrationRunner, useAccountCache, ); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 5f97b578824..0e9cee5c671 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -233,7 +233,6 @@ function getBgService(service: keyof MainBackground) { deps: [], }, { provide: TotpService, useFactory: getBgService("totpService"), deps: [] }, - { provide: TokenService, useFactory: getBgService("tokenService"), deps: [] }, { provide: I18nServiceAbstraction, useFactory: (globalStateProvider: GlobalStateProvider) => { @@ -445,6 +444,7 @@ function getBgService(service: keyof MainBackground) { logService: LogServiceAbstraction, accountService: AccountServiceAbstraction, environmentService: EnvironmentService, + tokenService: TokenService, migrationRunner: MigrationRunner, ) => { return new BrowserStateService( @@ -455,6 +455,7 @@ function getBgService(service: keyof MainBackground) { new StateFactory(GlobalState, Account), accountService, environmentService, + tokenService, migrationRunner, ); }, @@ -465,6 +466,7 @@ function getBgService(service: keyof MainBackground) { LogServiceAbstraction, AccountServiceAbstraction, EnvironmentService, + TokenService, MigrationRunner, ], }, diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 75e6479dc04..97c0974ac66 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -203,6 +203,7 @@ export class LoginCommand { ssoCodeVerifier, this.ssoRedirectUri, orgIdentifier, + undefined, // email to look up 2FA token not required as CLI can't remember 2FA token twoFactor, ), ); diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index f312c0c37e4..8e3b60e270f 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -310,6 +310,13 @@ export class Main { this.environmentService = new EnvironmentService(this.stateProvider, this.accountService); + this.tokenService = new TokenService( + this.singleUserStateProvider, + this.globalStateProvider, + this.platformUtilsService.supportsSecureStorage(), + this.secureStorageService, + ); + const migrationRunner = new MigrationRunner( this.storageService, this.logService, @@ -324,6 +331,7 @@ export class Main { new StateFactory(GlobalState, Account), this.accountService, this.environmentService, + this.tokenService, migrationRunner, ); @@ -341,7 +349,6 @@ export class Main { ); this.appIdService = new AppIdService(this.globalStateProvider); - this.tokenService = new TokenService(this.stateService); const customUserAgent = "Bitwarden_CLI/" + @@ -354,6 +361,7 @@ export class Main { this.platformUtilsService, this.environmentService, this.appIdService, + this.stateService, async (expired: boolean) => await this.logout(), customUserAgent, ); diff --git a/apps/cli/src/platform/services/node-api.service.ts b/apps/cli/src/platform/services/node-api.service.ts index d95e9d7d85e..9099fd27603 100644 --- a/apps/cli/src/platform/services/node-api.service.ts +++ b/apps/cli/src/platform/services/node-api.service.ts @@ -6,6 +6,7 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service" import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ApiService } from "@bitwarden/common/services/api.service"; (global as any).fetch = fe.default; @@ -20,6 +21,7 @@ export class NodeApiService extends ApiService { platformUtilsService: PlatformUtilsService, environmentService: EnvironmentService, appIdService: AppIdService, + stateService: StateService, logoutCallback: (expired: boolean) => Promise, customUserAgent: string = null, ) { @@ -28,6 +30,7 @@ export class NodeApiService extends ApiService { platformUtilsService, environmentService, appIdService, + stateService, logoutCallback, customUserAgent, ); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 2b103b8d715..3a8457da7a6 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -10,6 +10,7 @@ import { OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, WINDOW, + SUPPORTS_SECURE_STORAGE, SYSTEM_THEME_OBSERVABLE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; @@ -18,6 +19,7 @@ import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/adm import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { LoginService } from "@bitwarden/common/auth/services/login.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -54,7 +56,10 @@ import { LoginGuard } from "../../auth/guards/login.guard"; import { Account } from "../../models/account"; import { ElectronCryptoService } from "../../platform/services/electron-crypto.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; -import { ElectronPlatformUtilsService } from "../../platform/services/electron-platform-utils.service"; +import { + ELECTRON_SUPPORTS_SECURE_STORAGE, + ElectronPlatformUtilsService, +} from "../../platform/services/electron-platform-utils.service"; import { ElectronRendererMessagingService } from "../../platform/services/electron-renderer-messaging.service"; import { ElectronRendererSecureStorageService } from "../../platform/services/electron-renderer-secure-storage.service"; import { ElectronRendererStorageService } from "../../platform/services/electron-renderer-storage.service"; @@ -101,6 +106,13 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); useClass: ElectronPlatformUtilsService, deps: [I18nServiceAbstraction, MessagingServiceAbstraction], }, + { + // We manually override the value of SUPPORTS_SECURE_STORAGE here to avoid + // the TokenService having to inject the PlatformUtilsService which introduces a + // circular dependency on Desktop only. + provide: SUPPORTS_SECURE_STORAGE, + useValue: ELECTRON_SUPPORTS_SECURE_STORAGE, + }, { provide: I18nServiceAbstraction, useClass: I18nRendererService, @@ -140,6 +152,7 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); STATE_FACTORY, AccountServiceAbstraction, EnvironmentService, + TokenService, MigrationRunner, STATE_SERVICE_USE_CACHE, ], diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 80dfa04c274..0b92fab894e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2,7 +2,9 @@ import * as path from "path"; import { app } from "electron"; +import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; +import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { DefaultBiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; @@ -36,6 +38,7 @@ import { ClipboardMain } from "./platform/main/clipboard.main"; import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener"; import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service"; import { ElectronLogMainService } from "./platform/services/electron-log.main.service"; +import { ELECTRON_SUPPORTS_SECURE_STORAGE } from "./platform/services/electron-platform-utils.service"; import { ElectronStateService } from "./platform/services/electron-state.service"; import { ElectronStorageService } from "./platform/services/electron-storage.service"; import { I18nMainService } from "./platform/services/i18n.main.service"; @@ -53,6 +56,7 @@ export class Main { mainCryptoFunctionService: MainCryptoFunctionService; desktopCredentialStorageListener: DesktopCredentialStorageListener; migrationRunner: MigrationRunner; + tokenService: TokenServiceAbstraction; windowMain: WindowMain; messagingMain: MessagingMain; @@ -129,8 +133,13 @@ export class Main { stateEventRegistrarService, ); + const activeUserStateProvider = new DefaultActiveUserStateProvider( + accountService, + singleUserStateProvider, + ); + const stateProvider = new DefaultStateProvider( - new DefaultActiveUserStateProvider(accountService, singleUserStateProvider), + activeUserStateProvider, singleUserStateProvider, globalStateProvider, new DefaultDerivedStateProvider(this.memoryStorageForStateProviders), @@ -138,6 +147,13 @@ export class Main { this.environmentService = new EnvironmentService(stateProvider, accountService); + this.tokenService = new TokenService( + singleUserStateProvider, + globalStateProvider, + ELECTRON_SUPPORTS_SECURE_STORAGE, + this.storageService, + ); + this.migrationRunner = new MigrationRunner( this.storageService, this.logService, @@ -155,6 +171,7 @@ export class Main { new StateFactory(GlobalState, Account), accountService, // will not broadcast logouts. This is a hack until we can remove messaging dependency this.environmentService, + this.tokenService, this.migrationRunner, false, // Do not use disk caching because this will get out of sync with the renderer service ); @@ -176,6 +193,7 @@ export class Main { this.messagingService = new ElectronMainMessagingService(this.windowMain, (message) => { this.messagingMain.onMessage(message); }); + this.powerMonitorMain = new PowerMonitorMain(this.messagingService); this.menuMain = new MenuMain( this.i18nService, diff --git a/apps/desktop/src/platform/services/electron-platform-utils.service.ts b/apps/desktop/src/platform/services/electron-platform-utils.service.ts index 5f117ba678c..2d50712dfb6 100644 --- a/apps/desktop/src/platform/services/electron-platform-utils.service.ts +++ b/apps/desktop/src/platform/services/electron-platform-utils.service.ts @@ -8,6 +8,8 @@ import { import { ClipboardWriteMessage } from "../types/clipboard"; +export const ELECTRON_SUPPORTS_SECURE_STORAGE = true; + export class ElectronPlatformUtilsService implements PlatformUtilsService { constructor( protected i18nService: I18nService, @@ -142,7 +144,7 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { } supportsSecureStorage(): boolean { - return true; + return ELECTRON_SUPPORTS_SECURE_STORAGE; } getAutofillKeyboardShortcut(): Promise { diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts index 1ad3bd25c31..a80384d1798 100644 --- a/apps/web/src/app/core/state/state.service.ts +++ b/apps/web/src/app/core/state/state.service.ts @@ -7,6 +7,7 @@ import { STATE_SERVICE_USE_CACHE, } from "@bitwarden/angular/services/injection-tokens"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { @@ -33,6 +34,7 @@ export class StateService extends BaseStateService { @Inject(STATE_FACTORY) stateFactory: StateFactory, accountService: AccountService, environmentService: EnvironmentService, + tokenService: TokenService, migrationRunner: MigrationRunner, @Inject(STATE_SERVICE_USE_CACHE) useAccountCache = true, ) { @@ -44,6 +46,7 @@ export class StateService extends BaseStateService { stateFactory, accountService, environmentService, + tokenService, migrationRunner, useAccountCache, ); diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index 8314bdb2dcc..0ed1fd1dfe8 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -149,6 +149,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, this.captchaToken, null, ); + this.formPromise = this.loginStrategyService.logIn(credentials); const response = await this.formPromise; this.setFormValues(); @@ -302,6 +303,9 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, async saveEmailSettings() { this.setFormValues(); await this.loginService.saveEmailSettings(); + + // Save off email for SSO + await this.ssoLoginService.setSsoEmail(this.formGroup.value.email); } // Legacy accounts used the master key to encrypt data. Migration is required diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts index 2f50288f048..a5a08f9aefa 100644 --- a/libs/angular/src/auth/components/sso.component.ts +++ b/libs/angular/src/auth/components/sso.component.ts @@ -182,11 +182,14 @@ export class SsoComponent { private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise { this.loggingIn = true; try { + const email = await this.ssoLoginService.getSsoEmail(); + const credentials = new SsoLoginCredentials( code, codeVerifier, this.redirectUri, orgSsoIdentifier, + email, ); this.formPromise = this.loginStrategyService.logIn(credentials); const authResult = await this.formPromise; diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 50aafc23260..7d39078797e 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -42,6 +42,7 @@ export const LOGOUT_CALLBACK = new SafeInjectionToken< export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise>( "LOCKED_CALLBACK", ); +export const SUPPORTS_SECURE_STORAGE = new SafeInjectionToken("SUPPORTS_SECURE_STORAGE"); export const LOCALES_DIRECTORY = new SafeInjectionToken("LOCALES_DIRECTORY"); export const SYSTEM_LANGUAGE = new SafeInjectionToken("SYSTEM_LANGUAGE"); export const LOG_MAC_FAILURES = new SafeInjectionToken("LOG_MAC_FAILURES"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c5ab77e77b5..58d614bb9cc 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -250,6 +250,7 @@ import { SECURE_STORAGE, STATE_FACTORY, STATE_SERVICE_USE_CACHE, + SUPPORTS_SECURE_STORAGE, SYSTEM_LANGUAGE, SYSTEM_THEME_OBSERVABLE, WINDOW, @@ -272,6 +273,12 @@ const typesafeProviders: Array = [ useFactory: (i18nService: I18nServiceAbstraction) => i18nService.translationLocale, deps: [I18nServiceAbstraction], }), + safeProvider({ + provide: SUPPORTS_SECURE_STORAGE, + useFactory: (platformUtilsService: PlatformUtilsServiceAbstraction) => + platformUtilsService.supportsSecureStorage(), + deps: [PlatformUtilsServiceAbstraction], + }), safeProvider({ provide: LOCALES_DIRECTORY, useValue: "./locales", @@ -475,7 +482,12 @@ const typesafeProviders: Array = [ safeProvider({ provide: TokenServiceAbstraction, useClass: TokenService, - deps: [StateServiceAbstraction], + deps: [ + SingleUserStateProvider, + GlobalStateProvider, + SUPPORTS_SECURE_STORAGE, + AbstractStorageService, + ], }), safeProvider({ provide: KeyGenerationServiceAbstraction, @@ -519,6 +531,7 @@ const typesafeProviders: Array = [ PlatformUtilsServiceAbstraction, EnvironmentServiceAbstraction, AppIdServiceAbstraction, + StateServiceAbstraction, LOGOUT_CALLBACK, ], }), @@ -621,6 +634,7 @@ const typesafeProviders: Array = [ STATE_FACTORY, AccountServiceAbstraction, EnvironmentServiceAbstraction, + TokenServiceAbstraction, MigrationRunner, STATE_SERVICE_USE_CACHE, ], diff --git a/libs/auth/src/common/index.ts b/libs/auth/src/common/index.ts index f70f8be2153..936666e1a81 100644 --- a/libs/auth/src/common/index.ts +++ b/libs/auth/src/common/index.ts @@ -4,3 +4,4 @@ export * from "./abstractions"; export * from "./models"; export * from "./services"; +export * from "./utilities"; diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index dd046195aae..6a045a8f623 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -67,7 +67,7 @@ describe("AuthRequestLoginStrategy", () => { tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); - tokenService.decodeToken.mockResolvedValue({}); + tokenService.decodeAccessToken.mockResolvedValue({}); authRequestLoginStrategy = new AuthRequestLoginStrategy( cache, diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index acf21219c20..01a2c970776 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -79,7 +79,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy { credentials.email, credentials.accessCode, null, - await this.buildTwoFactor(credentials.twoFactor), + await this.buildTwoFactor(credentials.twoFactor, credentials.email), await this.buildDeviceRequest(), ); data.tokenRequest.setAuthRequestAccessCode(credentials.authRequestId); diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 5771cb2543f..a9938bd39c4 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -14,6 +14,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -123,11 +124,12 @@ describe("LoginStrategy", () => { logService = mock(); stateService = mock(); twoFactorService = mock(); + policyService = mock(); passwordStrengthService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); - tokenService.decodeToken.calledWith(accessToken).mockResolvedValue(decodedToken); + tokenService.decodeAccessToken.calledWith(accessToken).mockResolvedValue(decodedToken); // The base class is abstract so we test it via PasswordLoginStrategy passwordLoginStrategy = new PasswordLoginStrategy( @@ -167,8 +169,21 @@ describe("LoginStrategy", () => { const idTokenResponse = identityTokenResponseFactory(); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + const mockVaultTimeoutAction = VaultTimeoutAction.Lock; + const mockVaultTimeout = 1000; + + stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction); + stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout); + await passwordLoginStrategy.logIn(credentials); + expect(tokenService.setTokens).toHaveBeenCalledWith( + accessToken, + refreshToken, + mockVaultTimeoutAction, + mockVaultTimeout, + ); + expect(stateService.addAccount).toHaveBeenCalledWith( new Account({ profile: { @@ -184,10 +199,6 @@ describe("LoginStrategy", () => { }, tokens: { ...new AccountTokens(), - ...{ - accessToken: accessToken, - refreshToken: refreshToken, - }, }, keys: new AccountKeys(), decryptionOptions: AccountDecryptionOptions.fromResponse(idTokenResponse), @@ -299,6 +310,7 @@ describe("LoginStrategy", () => { expect(stateService.addAccount).not.toHaveBeenCalled(); expect(messagingService.send).not.toHaveBeenCalled(); + expect(tokenService.clearTwoFactorToken).toHaveBeenCalled(); const expected = new AuthResult(); expected.twoFactorProviders = new Map(); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index e6ff1c68a38..c6d441af236 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -16,6 +16,7 @@ import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { ClientType } from "@bitwarden/common/enums"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -49,6 +50,9 @@ export abstract class LoginStrategyData { | SsoTokenRequest | WebAuthnLoginTokenRequest; captchaBypassToken?: string; + + /** User's entered email obtained pre-login. */ + abstract userEnteredEmail?: string; } export abstract class LoginStrategy { @@ -110,21 +114,47 @@ export abstract class LoginStrategy { return new DeviceRequest(appId, this.platformUtilsService); } - protected async buildTwoFactor(userProvidedTwoFactor?: TokenTwoFactorRequest) { + /** + * Builds the TokenTwoFactorRequest to be used within other login strategies token requests + * to the server. + * If the user provided a 2FA token in an already created TokenTwoFactorRequest, it will be used. + * If not, and the user has previously remembered a 2FA token, it will be used. + * If neither of these are true, an empty TokenTwoFactorRequest will be returned. + * @param userProvidedTwoFactor - optional - The 2FA token request provided by the caller + * @param email - optional - ensure that email is provided for any login strategies that support remember 2FA functionality + * @returns a promise which resolves to a TokenTwoFactorRequest to be sent to the server + */ + protected async buildTwoFactor( + userProvidedTwoFactor?: TokenTwoFactorRequest, + email?: string, + ): Promise { if (userProvidedTwoFactor != null) { return userProvidedTwoFactor; } - const storedTwoFactorToken = await this.tokenService.getTwoFactorToken(); - if (storedTwoFactorToken != null) { - return new TokenTwoFactorRequest(TwoFactorProviderType.Remember, storedTwoFactorToken, false); + if (email) { + const storedTwoFactorToken = await this.tokenService.getTwoFactorToken(email); + if (storedTwoFactorToken != null) { + return new TokenTwoFactorRequest( + TwoFactorProviderType.Remember, + storedTwoFactorToken, + false, + ); + } } return new TokenTwoFactorRequest(); } - protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { - const accountInformation = await this.tokenService.decodeToken(tokenResponse.accessToken); + /** + * Initializes the account with information from the IdTokenResponse after successful login. + * It also sets the access token and refresh token in the token service. + * + * @param {IdentityTokenResponse} tokenResponse - The response from the server containing the identity token. + * @returns {Promise} - A promise that resolves when the account information has been successfully saved. + */ + protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise { + const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken); // Must persist existing device key if it exists for trusted device decryption to work // However, we must provide a user id so that the device key can be retrieved @@ -141,6 +171,18 @@ export abstract class LoginStrategy { // If you don't persist existing admin auth requests on login, they will get deleted. const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId }); + const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction(); + const vaultTimeout = await this.stateService.getVaultTimeout(); + + // set access token and refresh token before account initialization so authN status can be accurate + // User id will be derived from the access token. + await this.tokenService.setTokens( + tokenResponse.accessToken, + tokenResponse.refreshToken, + vaultTimeoutAction as VaultTimeoutAction, + vaultTimeout, + ); + await this.stateService.addAccount( new Account({ profile: { @@ -158,10 +200,6 @@ export abstract class LoginStrategy { }, tokens: { ...new AccountTokens(), - ...{ - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken, - }, }, keys: accountKeys, decryptionOptions: AccountDecryptionOptions.fromResponse(tokenResponse), @@ -193,7 +231,10 @@ export abstract class LoginStrategy { await this.saveAccountInformation(response); if (response.twoFactorToken != null) { - await this.tokenService.setTwoFactorToken(response); + // note: we can read email from access token b/c it was saved in saveAccountInformation + const userEmail = await this.tokenService.getEmail(); + + await this.tokenService.setTwoFactorToken(userEmail, response.twoFactorToken); } await this.setMasterKey(response); @@ -226,7 +267,18 @@ export abstract class LoginStrategy { } } + /** + * Handles the response from the server when a 2FA is required. + * It clears any existing 2FA token, as it's no longer valid, and sets up the necessary data for the 2FA process. + * + * @param {IdentityTwoFactorResponse} response - The response from the server indicating that 2FA is required. + * @returns {Promise} - A promise that resolves to an AuthResult object + */ private async processTwoFactorResponse(response: IdentityTwoFactorResponse): Promise { + // If we get a 2FA required response, then we should clear the 2FA token + // just in case as it is no longer valid. + await this.clearTwoFactorToken(); + const result = new AuthResult(); result.twoFactorProviders = response.twoFactorProviders2; @@ -237,6 +289,16 @@ export abstract class LoginStrategy { return result; } + /** + * Clears the 2FA token from the token service using the user's email if it exists + */ + private async clearTwoFactorToken() { + const email = this.cache.value.userEnteredEmail; + if (email) { + await this.tokenService.clearTwoFactorToken(email); + } + } + private async processCaptchaResponse(response: IdentityCaptchaResponse): Promise { const result = new AuthResult(); result.captchaSiteKey = response.siteKey; diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index 77ef6792ba0..1ab908ac9e0 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -81,7 +81,7 @@ describe("PasswordLoginStrategy", () => { passwordStrengthService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); - tokenService.decodeToken.mockResolvedValue({}); + tokenService.decodeAccessToken.mockResolvedValue({}); loginStrategyService.makePreloginKey.mockResolvedValue(masterKey); diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index c12eb28204b..2c99c243e07 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -32,6 +32,10 @@ import { LoginStrategy, LoginStrategyData } from "./login.strategy"; export class PasswordLoginStrategyData implements LoginStrategyData { tokenRequest: PasswordTokenRequest; + + /** User's entered email obtained pre-login. Always present in MP login. */ + userEnteredEmail: string; + captchaBypassToken?: string; /** * The local version of the user's master key hash @@ -105,6 +109,7 @@ export class PasswordLoginStrategy extends LoginStrategy { const data = new PasswordLoginStrategyData(); data.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email); + data.userEnteredEmail = email; // Hash the password early (before authentication) so we don't persist it in memory in plaintext data.localMasterKeyHash = await this.cryptoService.hashMasterKey( @@ -118,7 +123,7 @@ export class PasswordLoginStrategy extends LoginStrategy { email, masterKeyHash, captchaToken, - await this.buildTwoFactor(twoFactor), + await this.buildTwoFactor(twoFactor, email), await this.buildDeviceRequest(), ); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index b6cf6db58a0..9946a6141f7 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -71,7 +71,7 @@ describe("SsoLoginStrategy", () => { tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); - tokenService.decodeToken.mockResolvedValue({}); + tokenService.decodeAccessToken.mockResolvedValue({}); ssoLoginStrategy = new SsoLoginStrategy( null, diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index a5ef9222048..6b88a92f701 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -29,6 +29,10 @@ import { LoginStrategyData, LoginStrategy } from "./login.strategy"; export class SsoLoginStrategyData implements LoginStrategyData { captchaBypassToken: string; tokenRequest: SsoTokenRequest; + /** + * User's entered email obtained pre-login. Present in most SSO flows, but not CLI + SSO Flow. + */ + userEnteredEmail?: string; /** * User email address. Only available after authentication. */ @@ -105,11 +109,14 @@ export class SsoLoginStrategy extends LoginStrategy { async logIn(credentials: SsoLoginCredentials) { const data = new SsoLoginStrategyData(); data.orgId = credentials.orgId; + + data.userEnteredEmail = credentials.email; + data.tokenRequest = new SsoTokenRequest( credentials.code, credentials.codeVerifier, credentials.redirectUrl, - await this.buildTwoFactor(credentials.twoFactor), + await this.buildTwoFactor(credentials.twoFactor, credentials.email), await this.buildDeviceRequest(), ); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index d50d2883c76..da856a282eb 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -4,6 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -59,7 +60,7 @@ describe("UserApiLoginStrategy", () => { appIdService.getAppId.mockResolvedValue(deviceId); tokenService.getTwoFactorToken.mockResolvedValue(null); - tokenService.decodeToken.mockResolvedValue({}); + tokenService.decodeAccessToken.mockResolvedValue({}); apiLogInStrategy = new UserApiLoginStrategy( cache, @@ -101,10 +102,23 @@ describe("UserApiLoginStrategy", () => { it("sets the local environment after a successful login", async () => { apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); + const mockVaultTimeoutAction = VaultTimeoutAction.Lock; + const mockVaultTimeout = 60; + stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction); + stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout); + await apiLogInStrategy.logIn(credentials); - expect(stateService.setApiKeyClientId).toHaveBeenCalledWith(apiClientId); - expect(stateService.setApiKeyClientSecret).toHaveBeenCalledWith(apiClientSecret); + expect(tokenService.setClientId).toHaveBeenCalledWith( + apiClientId, + mockVaultTimeoutAction, + mockVaultTimeout, + ); + expect(tokenService.setClientSecret).toHaveBeenCalledWith( + apiClientSecret, + mockVaultTimeoutAction, + mockVaultTimeout, + ); expect(stateService.addAccount).toHaveBeenCalled(); }); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index a26fb41ae96..68916b6e8e1 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -7,6 +7,7 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service" import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -104,9 +105,21 @@ export class UserApiLoginStrategy extends LoginStrategy { protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { await super.saveAccountInformation(tokenResponse); + const vaultTimeout = await this.stateService.getVaultTimeout(); + const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction(); + const tokenRequest = this.cache.value.tokenRequest; - await this.stateService.setApiKeyClientId(tokenRequest.clientId); - await this.stateService.setApiKeyClientSecret(tokenRequest.clientSecret); + + await this.tokenService.setClientId( + tokenRequest.clientId, + vaultTimeoutAction as VaultTimeoutAction, + vaultTimeout, + ); + await this.tokenService.setClientSecret( + tokenRequest.clientSecret, + vaultTimeoutAction as VaultTimeoutAction, + vaultTimeout, + ); } exportCache(): CacheData { diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index 17933a3dcb6..b7a56e62308 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -71,7 +71,7 @@ describe("WebAuthnLoginStrategy", () => { tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); - tokenService.decodeToken.mockResolvedValue({}); + tokenService.decodeAccessToken.mockResolvedValue({}); webAuthnLoginStrategy = new WebAuthnLoginStrategy( cache, diff --git a/libs/auth/src/common/models/domain/login-credentials.ts b/libs/auth/src/common/models/domain/login-credentials.ts index a56d8e00970..bfe01aea20f 100644 --- a/libs/auth/src/common/models/domain/login-credentials.ts +++ b/libs/auth/src/common/models/domain/login-credentials.ts @@ -25,6 +25,11 @@ export class SsoLoginCredentials { public codeVerifier: string, public redirectUrl: string, public orgId: string, + /** + * Optional email address for SSO login. + * Used for looking up 2FA token on clients that support remembering 2FA token. + */ + public email?: string, public twoFactor?: TokenTwoFactorRequest, ) {} } diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts index 21509eb83c6..2304dc4d339 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts @@ -114,7 +114,7 @@ describe("LoginStrategyService", () => { token_type: "Bearer", }), ); - tokenService.decodeToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ + tokenService.decodeAccessToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ sub: "USER_ID", name: "NAME", email: "EMAIL", @@ -161,7 +161,7 @@ describe("LoginStrategyService", () => { }), ); - tokenService.decodeToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ + tokenService.decodeAccessToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ sub: "USER_ID", name: "NAME", email: "EMAIL", diff --git a/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.spec.ts b/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.spec.ts new file mode 100644 index 00000000000..84778b82f88 --- /dev/null +++ b/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.spec.ts @@ -0,0 +1,90 @@ +import { DecodedAccessToken } from "@bitwarden/common/auth/services/token.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { decodeJwtTokenToJson } from "./decode-jwt-token-to-json.utility"; + +describe("decodeJwtTokenToJson", () => { + const accessTokenJwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0IiwibmJmIjoxNzA5MzI0MTExLCJpYXQiOjE3MDkzMjQxMTEsImV4cCI6MTcwOTMyNzcxMSwic2NvcGUiOlsiYXBpIiwib2ZmbGluZV9hY2Nlc3MiXSwiYW1yIjpbIkFwcGxpY2F0aW9uIl0sImNsaWVudF9pZCI6IndlYiIsInN1YiI6ImVjZTcwYTEzLTcyMTYtNDNjNC05OTc3LWIxMDMwMTQ2ZTFlNyIsImF1dGhfdGltZSI6MTcwOTMyNDEwNCwiaWRwIjoiYml0d2FyZGVuIiwicHJlbWl1bSI6ZmFsc2UsImVtYWlsIjoiZXhhbXBsZUBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJzc3RhbXAiOiJHWTdKQU82NENLS1RLQkI2WkVBVVlMMldPUVU3QVNUMiIsIm5hbWUiOiJUZXN0IFVzZXIiLCJvcmdvd25lciI6WyI5MmI0OTkwOC1iNTE0LTQ1YTgtYmFkYi1iMTAzMDE0OGZlNTMiLCIzOGVkZTMyMi1iNGI0LTRiZDgtOWUwOS1iMTA3MDExMmRjMTEiLCJiMmQwNzAyOC1hNTgzLTRjM2UtOGQ2MC1iMTA3MDExOThjMjkiLCJiZjkzNGJhMi0wZmQ0LTQ5ZjItYTk1ZS1iMTA3MDExZmM5ZTYiLCJjMGI3Zjc1ZC0wMTVmLTQyYzktYjNhNi1iMTA4MDE3NjA3Y2EiXSwiZGV2aWNlIjoiNGI4NzIzNjctMGRhNi00MWEwLWFkY2ItNzdmMmZlZWZjNGY0IiwianRpIjoiNzUxNjFCRTQxMzFGRjVBMkRFNTExQjhDNEUyRkY4OUEifQ.n7roP8sSbfwcYdvRxZNZds27IK32TW6anorE6BORx_Q"; + + const accessTokenDecoded: DecodedAccessToken = { + iss: "http://localhost", + nbf: 1709324111, + iat: 1709324111, + exp: 1709327711, + scope: ["api", "offline_access"], + amr: ["Application"], + client_id: "web", + sub: "ece70a13-7216-43c4-9977-b1030146e1e7", // user id + auth_time: 1709324104, + idp: "bitwarden", + premium: false, + email: "example@bitwarden.com", + email_verified: false, + sstamp: "GY7JAO64CKKTKBB6ZEAUYL2WOQU7AST2", + name: "Test User", + orgowner: [ + "92b49908-b514-45a8-badb-b1030148fe53", + "38ede322-b4b4-4bd8-9e09-b1070112dc11", + "b2d07028-a583-4c3e-8d60-b10701198c29", + "bf934ba2-0fd4-49f2-a95e-b107011fc9e6", + "c0b7f75d-015f-42c9-b3a6-b108017607ca", + ], + device: "4b872367-0da6-41a0-adcb-77f2feefc4f4", + jti: "75161BE4131FF5A2DE511B8C4E2FF89A", + }; + + it("should decode the JWT token", () => { + // Act + const result = decodeJwtTokenToJson(accessTokenJwt); + + // Assert + expect(result).toEqual(accessTokenDecoded); + }); + + it("should throw an error if the JWT token is null", () => { + // Act && Assert + expect(() => decodeJwtTokenToJson(null)).toThrow("JWT token not found"); + }); + + it("should throw an error if the JWT token is missing 3 parts", () => { + // Act && Assert + expect(() => decodeJwtTokenToJson("invalidToken")).toThrow("JWT must have 3 parts"); + }); + + it("should throw an error if the JWT token payload contains invalid JSON", () => { + // Arrange: Create a token with a valid format but with a payload that's valid Base64 but not valid JSON + const header = btoa(JSON.stringify({ alg: "none" })); + // Create a Base64-encoded string which fails to parse as JSON + const payload = btoa("invalid JSON"); + const signature = "signature"; + const malformedToken = `${header}.${payload}.${signature}`; + + // Act & Assert + expect(() => decodeJwtTokenToJson(malformedToken)).toThrow( + "Cannot parse the token's payload into JSON", + ); + }); + + it("should throw an error if the JWT token cannot be decoded", () => { + // Arrange: Create a token with a valid format + const header = btoa(JSON.stringify({ alg: "none" })); + const payload = "invalidPayloadBecauseWeWillMockTheFailure"; + const signature = "signature"; + const malformedToken = `${header}.${payload}.${signature}`; + + // Mock Utils.fromUrlB64ToUtf8 to throw an error for this specific payload + jest.spyOn(Utils, "fromUrlB64ToUtf8").mockImplementation((input) => { + if (input === payload) { + throw new Error("Mock error"); + } + return input; // Default behavior for other inputs + }); + + // Act & Assert + expect(() => decodeJwtTokenToJson(malformedToken)).toThrow("Cannot decode the token"); + + // Restore original function so other tests are not affected + jest.restoreAllMocks(); + }); +}); diff --git a/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.ts b/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.ts new file mode 100644 index 00000000000..717e80b110d --- /dev/null +++ b/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.ts @@ -0,0 +1,32 @@ +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +export function decodeJwtTokenToJson(jwtToken: string): any { + if (jwtToken == null) { + throw new Error("JWT token not found"); + } + + const parts = jwtToken.split("."); + if (parts.length !== 3) { + throw new Error("JWT must have 3 parts"); + } + + // JWT has 3 parts: header, payload, signature separated by '.' + // So, grab the payload to decode + const encodedPayload = parts[1]; + + let decodedPayloadJSON: string; + try { + // Attempt to decode from URL-safe Base64 to UTF-8 + decodedPayloadJSON = Utils.fromUrlB64ToUtf8(encodedPayload); + } catch (decodingError) { + throw new Error("Cannot decode the token"); + } + + try { + // Attempt to parse the JSON payload + const decodedToken = JSON.parse(decodedPayloadJSON); + return decodedToken; + } catch (jsonError) { + throw new Error("Cannot parse the token's payload into JSON"); + } +} diff --git a/libs/auth/src/common/utilities/index.ts b/libs/auth/src/common/utilities/index.ts new file mode 100644 index 00000000000..0309e37f394 --- /dev/null +++ b/libs/auth/src/common/utilities/index.ts @@ -0,0 +1 @@ +export * from "./decode-jwt-token-to-json.utility"; diff --git a/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts b/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts index 4d73810320d..c964c8809c3 100644 --- a/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts @@ -54,6 +54,20 @@ export abstract class SsoLoginServiceAbstraction { * Do not use this value outside of the SSO login flow. */ setOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise; + /** + * Gets the user's email. + * Note: This should only be used during the SSO flow to identify the user that is attempting to log in. + * @returns The user's email. + */ + getSsoEmail: () => Promise; + /** + * Sets the user's email. + * Note: This should only be used during the SSO flow to identify the user that is attempting to log in. + * @param email The user's email. + * @returns A promise that resolves when the email has been set. + * + */ + setSsoEmail: (email: string) => Promise; /** * Gets the value of the active user's organization sso identifier. * diff --git a/libs/common/src/auth/abstractions/token.service.ts b/libs/common/src/auth/abstractions/token.service.ts index 88e6d489b32..d2358314d79 100644 --- a/libs/common/src/auth/abstractions/token.service.ts +++ b/libs/common/src/auth/abstractions/token.service.ts @@ -1,31 +1,208 @@ -import { IdentityTokenResponse } from "../models/response/identity-token.response"; +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { UserId } from "../../types/guid"; +import { DecodedAccessToken } from "../services/token.service"; export abstract class TokenService { + /** + * Sets the access token, refresh token, API Key Client ID, and API Key Client Secret in memory or disk + * based on the given vaultTimeoutAction and vaultTimeout and the derived access token user id. + * Note: for platforms that support secure storage, the access & refresh tokens are stored in secure storage instead of on disk. + * Note 2: this method also enforces always setting the access token and the refresh token together as + * we can retrieve the user id required to set the refresh token from the access token for efficiency. + * @param accessToken The access token to set. + * @param refreshToken The refresh token to set. + * @param clientIdClientSecret The API Key Client ID and Client Secret to set. + * @param vaultTimeoutAction The action to take when the vault times out. + * @param vaultTimeout The timeout for the vault. + * @returns A promise that resolves when the tokens have been set. + */ setTokens: ( accessToken: string, refreshToken: string, - clientIdClientSecret: [string, string], - ) => Promise; - setToken: (token: string) => Promise; - getToken: () => Promise; - setRefreshToken: (refreshToken: string) => Promise; - getRefreshToken: () => Promise; - setClientId: (clientId: string) => Promise; - getClientId: () => Promise; - setClientSecret: (clientSecret: string) => Promise; - getClientSecret: () => Promise; - setTwoFactorToken: (tokenResponse: IdentityTokenResponse) => Promise; - getTwoFactorToken: () => Promise; - clearTwoFactorToken: () => Promise; - clearToken: (userId?: string) => Promise; - decodeToken: (token?: string) => Promise; - getTokenExpirationDate: () => Promise; + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + clientIdClientSecret?: [string, string], + ) => Promise; + + /** + * Clears the access token, refresh token, API Key Client ID, and API Key Client Secret out of memory, disk, and secure storage if supported. + * @param userId The optional user id to clear the tokens for; if not provided, the active user id is used. + * @returns A promise that resolves when the tokens have been cleared. + */ + clearTokens: (userId?: UserId) => Promise; + + /** + * Sets the access token in memory or disk based on the given vaultTimeoutAction and vaultTimeout + * and the user id read off the access token + * Note: for platforms that support secure storage, the access & refresh tokens are stored in secure storage instead of on disk. + * @param accessToken The access token to set. + * @param vaultTimeoutAction The action to take when the vault times out. + * @param vaultTimeout The timeout for the vault. + * @returns A promise that resolves when the access token has been set. + */ + setAccessToken: ( + accessToken: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + ) => Promise; + + // TODO: revisit having this public clear method approach once the state service is fully deprecated. + /** + * Clears the access token for the given user id out of memory, disk, and secure storage if supported. + * @param userId The optional user id to clear the access token for; if not provided, the active user id is used. + * @returns A promise that resolves when the access token has been cleared. + * + * Note: This method is required so that the StateService doesn't have to inject the VaultTimeoutSettingsService to + * pass in the vaultTimeoutAction and vaultTimeout. + * This avoids a circular dependency between the StateService, TokenService, and VaultTimeoutSettingsService. + */ + clearAccessToken: (userId?: UserId) => Promise; + + /** + * Gets the access token + * @param userId - The optional user id to get the access token for; if not provided, the active user is used. + * @returns A promise that resolves with the access token or undefined. + */ + getAccessToken: (userId?: UserId) => Promise; + + /** + * Gets the refresh token. + * @param userId - The optional user id to get the refresh token for; if not provided, the active user is used. + * @returns A promise that resolves with the refresh token or undefined. + */ + getRefreshToken: (userId?: UserId) => Promise; + + /** + * Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. + * @param clientId The API Key Client ID to set. + * @param vaultTimeoutAction The action to take when the vault times out. + * @param vaultTimeout The timeout for the vault. + * @returns A promise that resolves when the API Key Client ID has been set. + */ + setClientId: ( + clientId: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId?: UserId, + ) => Promise; + + /** + * Gets the API Key Client ID for the active user. + * @returns A promise that resolves with the API Key Client ID or undefined + */ + getClientId: (userId?: UserId) => Promise; + + /** + * Sets the API Key Client Secret for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. + * @param clientSecret The API Key Client Secret to set. + * @param vaultTimeoutAction The action to take when the vault times out. + * @param vaultTimeout The timeout for the vault. + * @returns A promise that resolves when the API Key Client Secret has been set. + */ + setClientSecret: ( + clientSecret: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId?: UserId, + ) => Promise; + + /** + * Gets the API Key Client Secret for the active user. + * @returns A promise that resolves with the API Key Client Secret or undefined + */ + getClientSecret: (userId?: UserId) => Promise; + + /** + * Sets the two factor token for the given email in global state. + * The two factor token is set when the user checks "remember me" when completing two factor + * authentication and it is used to bypass two factor authentication for a period of time. + * @param email The email to set the two factor token for. + * @param twoFactorToken The two factor token to set. + * @returns A promise that resolves when the two factor token has been set. + */ + setTwoFactorToken: (email: string, twoFactorToken: string) => Promise; + + /** + * Gets the two factor token for the given email. + * @param email The email to get the two factor token for. + * @returns A promise that resolves with the two factor token for the given email or null if it isn't found. + */ + getTwoFactorToken: (email: string) => Promise; + + /** + * Clears the two factor token for the given email out of global state. + * @param email The email to clear the two factor token for. + * @returns A promise that resolves when the two factor token has been cleared. + */ + clearTwoFactorToken: (email: string) => Promise; + + /** + * Decodes the access token. + * @param token The access token to decode. + * @returns A promise that resolves with the decoded access token. + */ + decodeAccessToken: (token?: string) => Promise; + + /** + * Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration + * @returns A promise that resolves with the expiration date for the access token. + */ + getTokenExpirationDate: () => Promise; + + /** + * Calculates the adjusted time in seconds until the access token expires, considering an optional offset. + * + * @param {number} [offsetSeconds=0] Optional seconds to subtract from the remaining time, + * creating a buffer before actual expiration. Useful for preemptive actions + * before token expiry. A value of 0 or omitting this parameter calculates time + * based on the actual expiration. + * @returns {Promise} Promise resolving to the adjusted seconds remaining. + */ tokenSecondsRemaining: (offsetSeconds?: number) => Promise; + + /** + * Checks if the access token needs to be refreshed. + * @param {number} [minutes=5] - Optional number of minutes before the access token expires to consider refreshing it. + * @returns A promise that resolves with a boolean indicating if the access token needs to be refreshed. + */ tokenNeedsRefresh: (minutes?: number) => Promise; - getUserId: () => Promise; + + /** + * Gets the user id for the active user from the access token. + * @returns A promise that resolves with the user id for the active user. + * @deprecated Use AccountService.activeAccount$ instead. + */ + getUserId: () => Promise; + + /** + * Gets the email for the active user from the access token. + * @returns A promise that resolves with the email for the active user. + * @deprecated Use AccountService.activeAccount$ instead. + */ getEmail: () => Promise; + + /** + * Gets the email verified status for the active user from the access token. + * @returns A promise that resolves with the email verified status for the active user. + */ getEmailVerified: () => Promise; + + /** + * Gets the name for the active user from the access token. + * @returns A promise that resolves with the name for the active user. + * @deprecated Use AccountService.activeAccount$ instead. + */ getName: () => Promise; + + /** + * Gets the issuer for the active user from the access token. + * @returns A promise that resolves with the issuer for the active user. + */ getIssuer: () => Promise; + + /** + * Gets whether or not the user authenticated via an external mechanism. + * @returns A promise that resolves with a boolean representing the user's external authN status. + */ getIsExternal: () => Promise; } diff --git a/libs/common/src/auth/services/sso-login.service.ts b/libs/common/src/auth/services/sso-login.service.ts index e693de44fc0..99640e1c6c6 100644 --- a/libs/common/src/auth/services/sso-login.service.ts +++ b/libs/common/src/auth/services/sso-login.service.ts @@ -7,6 +7,7 @@ import { SSO_DISK, StateProvider, } from "../../platform/state"; +import { SsoLoginServiceAbstraction } from "../abstractions/sso-login.service.abstraction"; /** * Uses disk storage so that the code verifier can be persisted across sso redirects. @@ -33,16 +34,25 @@ const ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition( }, ); -export class SsoLoginService { +/** + * Uses disk storage so that the user's email can be persisted across sso redirects. + */ +const SSO_EMAIL = new KeyDefinition(SSO_DISK, "ssoEmail", { + deserializer: (state) => state, +}); + +export class SsoLoginService implements SsoLoginServiceAbstraction { private codeVerifierState: GlobalState; private ssoState: GlobalState; private orgSsoIdentifierState: GlobalState; + private ssoEmailState: GlobalState; private activeUserOrgSsoIdentifierState: ActiveUserState; constructor(private stateProvider: StateProvider) { this.codeVerifierState = this.stateProvider.getGlobal(CODE_VERIFIER); this.ssoState = this.stateProvider.getGlobal(SSO_STATE); this.orgSsoIdentifierState = this.stateProvider.getGlobal(ORGANIZATION_SSO_IDENTIFIER); + this.ssoEmailState = this.stateProvider.getGlobal(SSO_EMAIL); this.activeUserOrgSsoIdentifierState = this.stateProvider.getActive( ORGANIZATION_SSO_IDENTIFIER, ); @@ -72,6 +82,14 @@ export class SsoLoginService { await this.orgSsoIdentifierState.update((_) => organizationIdentifier); } + getSsoEmail(): Promise { + return firstValueFrom(this.ssoEmailState.state$); + } + + async setSsoEmail(email: string): Promise { + await this.ssoEmailState.update((_) => email); + } + getActiveUserOrganizationSsoIdentifier(): Promise { return firstValueFrom(this.activeUserOrgSsoIdentifierState.state$); } diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts new file mode 100644 index 00000000000..a7b953f9280 --- /dev/null +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -0,0 +1,2237 @@ +import { mock } from "jest-mock-extended"; + +import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec"; +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { AbstractStorageService } from "../../platform/abstractions/storage.service"; +import { StorageLocation } from "../../platform/enums"; +import { StorageOptions } from "../../platform/models/domain/storage-options"; +import { UserId } from "../../types/guid"; + +import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service"; +import { DecodedAccessToken, TokenService } from "./token.service"; +import { + ACCESS_TOKEN_DISK, + ACCESS_TOKEN_MEMORY, + ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, + API_KEY_CLIENT_ID_DISK, + API_KEY_CLIENT_ID_MEMORY, + API_KEY_CLIENT_SECRET_DISK, + API_KEY_CLIENT_SECRET_MEMORY, + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + REFRESH_TOKEN_DISK, + REFRESH_TOKEN_MEMORY, + REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, +} from "./token.state"; + +describe("TokenService", () => { + let tokenService: TokenService; + let singleUserStateProvider: FakeSingleUserStateProvider; + let globalStateProvider: FakeGlobalStateProvider; + + const secureStorageService = mock(); + + const memoryVaultTimeoutAction = VaultTimeoutAction.LogOut; + const memoryVaultTimeout = 30; + + const diskVaultTimeoutAction = VaultTimeoutAction.Lock; + const diskVaultTimeout: number = null; + + const accessTokenJwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0IiwibmJmIjoxNzA5MzI0MTExLCJpYXQiOjE3MDkzMjQxMTEsImV4cCI6MTcwOTMyNzcxMSwic2NvcGUiOlsiYXBpIiwib2ZmbGluZV9hY2Nlc3MiXSwiYW1yIjpbIkFwcGxpY2F0aW9uIl0sImNsaWVudF9pZCI6IndlYiIsInN1YiI6ImVjZTcwYTEzLTcyMTYtNDNjNC05OTc3LWIxMDMwMTQ2ZTFlNyIsImF1dGhfdGltZSI6MTcwOTMyNDEwNCwiaWRwIjoiYml0d2FyZGVuIiwicHJlbWl1bSI6ZmFsc2UsImVtYWlsIjoiZXhhbXBsZUBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJzc3RhbXAiOiJHWTdKQU82NENLS1RLQkI2WkVBVVlMMldPUVU3QVNUMiIsIm5hbWUiOiJUZXN0IFVzZXIiLCJvcmdvd25lciI6WyI5MmI0OTkwOC1iNTE0LTQ1YTgtYmFkYi1iMTAzMDE0OGZlNTMiLCIzOGVkZTMyMi1iNGI0LTRiZDgtOWUwOS1iMTA3MDExMmRjMTEiLCJiMmQwNzAyOC1hNTgzLTRjM2UtOGQ2MC1iMTA3MDExOThjMjkiLCJiZjkzNGJhMi0wZmQ0LTQ5ZjItYTk1ZS1iMTA3MDExZmM5ZTYiLCJjMGI3Zjc1ZC0wMTVmLTQyYzktYjNhNi1iMTA4MDE3NjA3Y2EiXSwiZGV2aWNlIjoiNGI4NzIzNjctMGRhNi00MWEwLWFkY2ItNzdmMmZlZWZjNGY0IiwianRpIjoiNzUxNjFCRTQxMzFGRjVBMkRFNTExQjhDNEUyRkY4OUEifQ.n7roP8sSbfwcYdvRxZNZds27IK32TW6anorE6BORx_Q"; + + const accessTokenDecoded: DecodedAccessToken = { + iss: "http://localhost", + nbf: 1709324111, + iat: 1709324111, + exp: 1709327711, + scope: ["api", "offline_access"], + amr: ["Application"], + client_id: "web", + sub: "ece70a13-7216-43c4-9977-b1030146e1e7", // user id + auth_time: 1709324104, + idp: "bitwarden", + premium: false, + email: "example@bitwarden.com", + email_verified: false, + sstamp: "GY7JAO64CKKTKBB6ZEAUYL2WOQU7AST2", + name: "Test User", + orgowner: [ + "92b49908-b514-45a8-badb-b1030148fe53", + "38ede322-b4b4-4bd8-9e09-b1070112dc11", + "b2d07028-a583-4c3e-8d60-b10701198c29", + "bf934ba2-0fd4-49f2-a95e-b107011fc9e6", + "c0b7f75d-015f-42c9-b3a6-b108017607ca", + ], + device: "4b872367-0da6-41a0-adcb-77f2feefc4f4", + jti: "75161BE4131FF5A2DE511B8C4E2FF89A", + }; + + const userIdFromAccessToken: UserId = accessTokenDecoded.sub as UserId; + + const secureStorageOptions: StorageOptions = { + storageLocation: StorageLocation.Disk, + useSecureStorage: true, + userId: userIdFromAccessToken, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + singleUserStateProvider = new FakeSingleUserStateProvider(); + globalStateProvider = new FakeGlobalStateProvider(); + + const supportsSecureStorage = false; // default to false; tests will override as needed + tokenService = createTokenService(supportsSecureStorage); + }); + + it("instantiates", () => { + expect(tokenService).not.toBeFalsy(); + }); + + describe("Access Token methods", () => { + const accessTokenPartialSecureStorageKey = `_accessToken`; + const accessTokenSecureStorageKey = `${userIdFromAccessToken}${accessTokenPartialSecureStorageKey}`; + + describe("setAccessToken", () => { + it("should throw an error if the access token is null", async () => { + // Act + const result = tokenService.setAccessToken(null, VaultTimeoutAction.Lock, null); + // Assert + await expect(result).rejects.toThrow("Access token is required."); + }); + + it("should throw an error if an invalid token is passed in", async () => { + // Act + const result = tokenService.setAccessToken("invalidToken", VaultTimeoutAction.Lock, null); + // Assert + await expect(result).rejects.toThrow("JWT must have 3 parts"); + }); + + it("should not throw an error as long as the token is valid", async () => { + // Act + const result = tokenService.setAccessToken(accessTokenJwt, VaultTimeoutAction.Lock, null); + // Assert + await expect(result).resolves.not.toThrow(); + }); + + describe("Memory storage tests", () => { + it("should set the access token in memory", async () => { + // Act + await tokenService.setAccessToken( + accessTokenJwt, + memoryVaultTimeoutAction, + memoryVaultTimeout, + ); + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(accessTokenJwt); + }); + }); + + describe("Disk storage tests (secure storage not supported on platform)", () => { + it("should set the access token in disk", async () => { + // Act + await tokenService.setAccessToken( + accessTokenJwt, + diskVaultTimeoutAction, + diskVaultTimeout, + ); + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(accessTokenJwt); + }); + }); + + describe("Disk storage tests (secure storage supported on platform)", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should set the access token in secure storage, null out data on disk or in memory, and set a flag to indicate the token has been migrated", async () => { + // Arrange: + + // For testing purposes, let's assume that the access token is already in disk and memory + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Act + await tokenService.setAccessToken( + accessTokenJwt, + diskVaultTimeoutAction, + diskVaultTimeout, + ); + // Assert + + // assert that the access token was set in secure storage + expect(secureStorageService.save).toHaveBeenCalledWith( + accessTokenSecureStorageKey, + accessTokenJwt, + secureStorageOptions, + ); + + // assert data was migrated out of disk and memory + flag was set + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + + expect( + singleUserStateProvider.getFake( + userIdFromAccessToken, + ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, + ).nextMock, + ).toHaveBeenCalledWith(true); + }); + }); + }); + + describe("getAccessToken", () => { + it("should return undefined if no user id is provided and there is no active user in global state", async () => { + // Act + const result = await tokenService.getAccessToken(); + // Assert + expect(result).toBeUndefined(); + }); + + it("should return null if no access token is found in memory, disk, or secure storage", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getAccessToken(); + // Assert + expect(result).toBeNull(); + }); + + describe("Memory storage tests", () => { + it("should get the access token from memory with no user id specified (uses global active user)", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getAccessToken(); + + // Assert + expect(result).toEqual(accessTokenJwt); + }); + + it("should get the access token from memory for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(accessTokenJwt); + }); + }); + + describe("Disk storage tests (secure storage not supported on platform)", () => { + it("should get the access token from disk with no user id specified", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getAccessToken(); + // Assert + expect(result).toEqual(accessTokenJwt); + }); + + it("should get the access token from disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(accessTokenJwt); + }); + }); + + describe("Disk storage tests (secure storage supported on platform)", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should get the access token from secure storage when no user id is specified and the migration flag is set to true", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + secureStorageService.get.mockResolvedValue(accessTokenJwt); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // set access token migration flag to true + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, true]); + + // Act + const result = await tokenService.getAccessToken(); + // Assert + expect(result).toEqual(accessTokenJwt); + }); + + it("should get the access token from secure storage when user id is specified and the migration flag set to true", async () => { + // Arrange + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + secureStorageService.get.mockResolvedValue(accessTokenJwt); + + // set access token migration flag to true + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, true]); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(accessTokenJwt); + }); + + it("should fallback and get the access token from disk when user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // set access token migration flag to false + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, false]); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + + // Assert + expect(result).toEqual(accessTokenJwt); + + // assert that secure storage was not called + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + + it("should fallback and get the access token from disk when no user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // set access token migration flag to false + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, false]); + + // Act + const result = await tokenService.getAccessToken(); + + // Assert + expect(result).toEqual(accessTokenJwt); + + // assert that secure storage was not called + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + }); + }); + + describe("clearAccessToken", () => { + it("should throw an error if no user id is provided and there is no active user in global state", async () => { + // Act + // note: don't await here because we want to test the error + const result = tokenService.clearAccessToken(); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot clear access token."); + }); + + describe("Secure storage enabled", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should clear the access token from all storage locations for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Act + await tokenService.clearAccessToken(userIdFromAccessToken); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + + expect(secureStorageService.remove).toHaveBeenCalledWith( + accessTokenSecureStorageKey, + secureStorageOptions, + ); + }); + + it("should clear the access token from all storage locations for the global active user", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await tokenService.clearAccessToken(); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + + expect(secureStorageService.remove).toHaveBeenCalledWith( + accessTokenSecureStorageKey, + secureStorageOptions, + ); + }); + }); + }); + + describe("decodeAccessToken", () => { + it("should throw an error if no access token provided or retrieved from state", async () => { + // Access + tokenService.getAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.decodeAccessToken(); + // Assert + await expect(result).rejects.toThrow("Access token not found."); + }); + + it("should decode the access token", async () => { + // Arrange + tokenService.getAccessToken = jest.fn().mockResolvedValue(accessTokenJwt); + + // Act + const result = await tokenService.decodeAccessToken(); + + // Assert + expect(result).toEqual(accessTokenDecoded); + }); + }); + + describe("Data methods", () => { + describe("getTokenExpirationDate", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getTokenExpirationDate(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should return null if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + const result = await tokenService.getTokenExpirationDate(); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null if the decoded access token does not have an expiration date", async () => { + // Arrange + const accessTokenDecodedWithoutExp = { ...accessTokenDecoded }; + delete accessTokenDecodedWithoutExp.exp; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithoutExp); + + // Act + const result = await tokenService.getTokenExpirationDate(); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null if the decoded access token has an non numeric expiration date", async () => { + // Arrange + const accessTokenDecodedWithNonNumericExp = { ...accessTokenDecoded, exp: "non-numeric" }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonNumericExp); + + // Act + const result = await tokenService.getTokenExpirationDate(); + + // Assert + expect(result).toBeNull(); + }); + + it("should return the expiration date of the access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getTokenExpirationDate(); + + // Assert + expect(result).toEqual(new Date(accessTokenDecoded.exp * 1000)); + }); + }); + + describe("tokenSecondsRemaining", () => { + it("should return 0 if the tokenExpirationDate is null", async () => { + // Arrange + tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(null); + + // Act + const result = await tokenService.tokenSecondsRemaining(); + + // Assert + expect(result).toEqual(0); + }); + + it("should return the number of seconds remaining until the token expires", async () => { + // Arrange + // Lock the time to ensure a consistent test environment + // otherwise we have flaky issues with set system time date and the Date.now() call. + const fixedCurrentTime = new Date("2024-03-06T00:00:00Z"); + jest.useFakeTimers().setSystemTime(fixedCurrentTime); + + const nowInSeconds = Math.floor(Date.now() / 1000); + const expirationInSeconds = nowInSeconds + 3600; // token expires in 1 hr + const expectedSecondsRemaining = expirationInSeconds - nowInSeconds; + + const expirationDate = new Date(0); + expirationDate.setUTCSeconds(expirationInSeconds); + tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate); + + // Act + const result = await tokenService.tokenSecondsRemaining(); + + // Assert + expect(result).toEqual(expectedSecondsRemaining); + + // Reset the timers to be the real ones + jest.useRealTimers(); + }); + + it("should return the number of seconds remaining until the token expires, considering an offset", async () => { + // Arrange + // Lock the time to ensure a consistent test environment + // otherwise we have flaky issues with set system time date and the Date.now() call. + const fixedCurrentTime = new Date("2024-03-06T00:00:00Z"); + jest.useFakeTimers().setSystemTime(fixedCurrentTime); + + const nowInSeconds = Math.floor(Date.now() / 1000); + const offsetSeconds = 300; // 5 minute offset + const expirationInSeconds = nowInSeconds + 3600; // token expires in 1 hr + const expectedSecondsRemaining = expirationInSeconds - nowInSeconds - offsetSeconds; // Adjust for offset + + const expirationDate = new Date(0); + expirationDate.setUTCSeconds(expirationInSeconds); + tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate); + + // Act + const result = await tokenService.tokenSecondsRemaining(offsetSeconds); + + // Assert + expect(result).toEqual(expectedSecondsRemaining); + + // Reset the timers to be the real ones + jest.useRealTimers(); + }); + }); + + describe("tokenNeedsRefresh", () => { + it("should return true if token is within the default refresh threshold (5 min)", async () => { + // Arrange + const tokenSecondsRemaining = 60; + tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); + + // Act + const result = await tokenService.tokenNeedsRefresh(); + + // Assert + expect(result).toEqual(true); + }); + + it("should return false if token is outside the default refresh threshold (5 min)", async () => { + // Arrange + const tokenSecondsRemaining = 600; + tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); + + // Act + const result = await tokenService.tokenNeedsRefresh(); + + // Assert + expect(result).toEqual(false); + }); + + it("should return true if token is within the specified refresh threshold", async () => { + // Arrange + const tokenSecondsRemaining = 60; + tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); + + // Act + const result = await tokenService.tokenNeedsRefresh(2); + + // Assert + expect(result).toEqual(true); + }); + + it("should return false if token is outside the specified refresh threshold", async () => { + // Arrange + const tokenSecondsRemaining = 600; + tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); + + // Act + const result = await tokenService.tokenNeedsRefresh(5); + + // Assert + expect(result).toEqual(false); + }); + }); + + describe("getUserId", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getUserId(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should throw an error if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getUserId(); + // Assert + await expect(result).rejects.toThrow("No user id found"); + }); + + it("should throw an error if the decoded access token has a non-string user id", async () => { + // Arrange + const accessTokenDecodedWithNonStringSub = { ...accessTokenDecoded, sub: 123 }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonStringSub); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getUserId(); + // Assert + await expect(result).rejects.toThrow("No user id found"); + }); + + it("should return the user id from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getUserId(); + + // Assert + expect(result).toEqual(userIdFromAccessToken); + }); + }); + + describe("getUserIdFromAccessToken", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).getUserIdFromAccessToken(accessTokenJwt); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should throw an error if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).getUserIdFromAccessToken(accessTokenJwt); + // Assert + await expect(result).rejects.toThrow("No user id found"); + }); + + it("should throw an error if the decoded access token has a non-string user id", async () => { + // Arrange + const accessTokenDecodedWithNonStringSub = { ...accessTokenDecoded, sub: 123 }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonStringSub); + + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).getUserIdFromAccessToken(accessTokenJwt); + // Assert + await expect(result).rejects.toThrow("No user id found"); + }); + + it("should return the user id from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await (tokenService as any).getUserIdFromAccessToken(accessTokenJwt); + + // Assert + expect(result).toEqual(userIdFromAccessToken); + }); + }); + + describe("getEmail", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmail(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should throw an error if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmail(); + // Assert + await expect(result).rejects.toThrow("No email found"); + }); + + it("should throw an error if the decoded access token has a non-string email", async () => { + // Arrange + const accessTokenDecodedWithNonStringEmail = { ...accessTokenDecoded, email: 123 }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonStringEmail); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmail(); + // Assert + await expect(result).rejects.toThrow("No email found"); + }); + + it("should return the email from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getEmail(); + + // Assert + expect(result).toEqual(accessTokenDecoded.email); + }); + }); + + describe("getEmailVerified", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmailVerified(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should throw an error if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmailVerified(); + // Assert + await expect(result).rejects.toThrow("No email verification found"); + }); + + it("should throw an error if the decoded access token has a non-boolean email_verified", async () => { + // Arrange + const accessTokenDecodedWithNonBooleanEmailVerified = { + ...accessTokenDecoded, + email_verified: 123, + }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonBooleanEmailVerified); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmailVerified(); + // Assert + await expect(result).rejects.toThrow("No email verification found"); + }); + + it("should return the email_verified from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getEmailVerified(); + + // Assert + expect(result).toEqual(accessTokenDecoded.email_verified); + }); + }); + + describe("getName", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getName(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should return null if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + const result = await tokenService.getName(); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null if the decoded access token has a non-string name", async () => { + // Arrange + const accessTokenDecodedWithNonStringName = { ...accessTokenDecoded, name: 123 }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonStringName); + + // Act + const result = await tokenService.getName(); + + // Assert + expect(result).toBeNull(); + }); + + it("should return the name from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getName(); + + // Assert + expect(result).toEqual(accessTokenDecoded.name); + }); + }); + + describe("getIssuer", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getIssuer(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should throw an error if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getIssuer(); + // Assert + await expect(result).rejects.toThrow("No issuer found"); + }); + + it("should throw an error if the decoded access token has a non-string iss", async () => { + // Arrange + const accessTokenDecodedWithNonStringIss = { ...accessTokenDecoded, iss: 123 }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonStringIss); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getIssuer(); + // Assert + await expect(result).rejects.toThrow("No issuer found"); + }); + + it("should return the issuer from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getIssuer(); + + // Assert + expect(result).toEqual(accessTokenDecoded.iss); + }); + }); + + describe("getIsExternal", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getIsExternal(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should return false if the amr (Authentication Method Reference) claim does not contain 'external'", async () => { + // Arrange + const accessTokenDecodedWithoutExternalAmr = { + ...accessTokenDecoded, + amr: ["not-external"], + }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithoutExternalAmr); + + // Act + const result = await tokenService.getIsExternal(); + + // Assert + expect(result).toEqual(false); + }); + + it("should return true if the amr (Authentication Method Reference) claim contains 'external'", async () => { + // Arrange + const accessTokenDecodedWithExternalAmr = { + ...accessTokenDecoded, + amr: ["external"], + }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithExternalAmr); + + // Act + const result = await tokenService.getIsExternal(); + + // Assert + expect(result).toEqual(true); + }); + }); + }); + }); + + describe("Refresh Token methods", () => { + const refreshToken = "refreshToken"; + const refreshTokenPartialSecureStorageKey = `_refreshToken`; + const refreshTokenSecureStorageKey = `${userIdFromAccessToken}${refreshTokenPartialSecureStorageKey}`; + + describe("setRefreshToken", () => { + it("should throw an error if no user id is provided", async () => { + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).setRefreshToken( + refreshToken, + VaultTimeoutAction.Lock, + null, + ); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot save refresh token."); + }); + + describe("Memory storage tests", () => { + it("should set the refresh token in memory for the specified user id", async () => { + // Act + await (tokenService as any).setRefreshToken( + refreshToken, + memoryVaultTimeoutAction, + memoryVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(refreshToken); + }); + }); + + describe("Disk storage tests (secure storage not supported on platform)", () => { + it("should set the refresh token in disk for the specified user id", async () => { + // Act + await (tokenService as any).setRefreshToken( + refreshToken, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(refreshToken); + }); + }); + + describe("Disk storage tests (secure storage supported on platform)", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should set the refresh token in secure storage, null out data on disk or in memory, and set a flag to indicate the token has been migrated for the specified user id", async () => { + // Arrange: + // For testing purposes, let's assume that the token is already in disk and memory + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // Act + await (tokenService as any).setRefreshToken( + refreshToken, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + // Assert + + // assert that the refresh token was set in secure storage + expect(secureStorageService.save).toHaveBeenCalledWith( + refreshTokenSecureStorageKey, + refreshToken, + secureStorageOptions, + ); + + // assert data was migrated out of disk and memory + flag was set + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + + expect( + singleUserStateProvider.getFake( + userIdFromAccessToken, + REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, + ).nextMock, + ).toHaveBeenCalledWith(true); + }); + }); + }); + + describe("getRefreshToken", () => { + it("should return undefined if no user id is provided and there is no active user in global state", async () => { + // Act + const result = await (tokenService as any).getRefreshToken(); + // Assert + expect(result).toBeUndefined(); + }); + + it("should return null if no refresh token is found in memory, disk, or secure storage", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await (tokenService as any).getRefreshToken(); + // Assert + expect(result).toBeNull(); + }); + + describe("Memory storage tests", () => { + it("should get the refresh token from memory with no user id specified (uses global active user)", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getRefreshToken(); + + // Assert + expect(result).toEqual(refreshToken); + }); + + it("should get the refresh token from memory for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(refreshToken); + }); + }); + + describe("Disk storage tests (secure storage not supported on platform)", () => { + it("should get the refresh token from disk with no user id specified", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getRefreshToken(); + // Assert + expect(result).toEqual(refreshToken); + }); + + it("should get the refresh token from disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(refreshToken); + }); + }); + + describe("Disk storage tests (secure storage supported on platform)", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should get the refresh token from secure storage when no user id is specified and the migration flag is set to true", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + secureStorageService.get.mockResolvedValue(refreshToken); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // set access token migration flag to true + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, true]); + + // Act + const result = await tokenService.getRefreshToken(); + // Assert + expect(result).toEqual(refreshToken); + }); + + it("should get the refresh token from secure storage when user id is specified and the migration flag set to true", async () => { + // Arrange + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + secureStorageService.get.mockResolvedValue(refreshToken); + + // set access token migration flag to true + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, true]); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(refreshToken); + }); + + it("should fallback and get the refresh token from disk when user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // set refresh token migration flag to false + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, false]); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + + // Assert + expect(result).toEqual(refreshToken); + + // assert that secure storage was not called + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + + it("should fallback and get the refresh token from disk when no user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // set access token migration flag to false + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, false]); + + // Act + const result = await tokenService.getRefreshToken(); + + // Assert + expect(result).toEqual(refreshToken); + + // assert that secure storage was not called + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + }); + }); + + describe("clearRefreshToken", () => { + it("should throw an error if no user id is provided", async () => { + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).clearRefreshToken(); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot clear refresh token."); + }); + + describe("Secure storage enabled", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should clear the refresh token from all storage locations for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // Act + await (tokenService as any).clearRefreshToken(userIdFromAccessToken); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + + expect(secureStorageService.remove).toHaveBeenCalledWith( + refreshTokenSecureStorageKey, + secureStorageOptions, + ); + }); + }); + }); + }); + + describe("Client Id methods", () => { + const clientId = "clientId"; + + describe("setClientId", () => { + it("should throw an error if no user id is provided and there is no active user in global state", async () => { + // Act + // note: don't await here because we want to test the error + const result = tokenService.setClientId(clientId, VaultTimeoutAction.Lock, null); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot save client id."); + }); + + describe("Memory storage tests", () => { + it("should set the client id in memory when there is an active user in global state", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await tokenService.setClientId(clientId, memoryVaultTimeoutAction, memoryVaultTimeout); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(clientId); + }); + + it("should set the client id in memory for the specified user id", async () => { + // Act + await tokenService.setClientId( + clientId, + memoryVaultTimeoutAction, + memoryVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(clientId); + }); + }); + + describe("Disk storage tests", () => { + it("should set the client id in disk when there is an active user in global state", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await tokenService.setClientId(clientId, diskVaultTimeoutAction, diskVaultTimeout); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock, + ).toHaveBeenCalledWith(clientId); + }); + + it("should set the client id in disk for the specified user id", async () => { + // Act + await tokenService.setClientId( + clientId, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock, + ).toHaveBeenCalledWith(clientId); + }); + }); + }); + + describe("getClientId", () => { + it("should return undefined if no user id is provided and there is no active user in global state", async () => { + // Act + const result = await tokenService.getClientId(); + // Assert + expect(result).toBeUndefined(); + }); + + it("should return null if no client id is found in memory or disk", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientId(); + // Assert + expect(result).toBeNull(); + }); + + describe("Memory storage tests", () => { + it("should get the client id from memory with no user id specified (uses global active user)", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientId(); + + // Assert + expect(result).toEqual(clientId); + }); + + it("should get the client id from memory for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Act + const result = await tokenService.getClientId(userIdFromAccessToken); + // Assert + expect(result).toEqual(clientId); + }); + }); + + describe("Disk storage tests", () => { + it("should get the client id from disk with no user id specified", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientId(); + // Assert + expect(result).toEqual(clientId); + }); + + it("should get the client id from disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // Act + const result = await tokenService.getClientId(userIdFromAccessToken); + // Assert + expect(result).toEqual(clientId); + }); + }); + }); + + describe("clearClientId", () => { + it("should throw an error if no user id is provided and there is no active user in global state", async () => { + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).clearClientId(); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot clear client id."); + }); + + it("should clear the client id from memory and disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientId]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // Act + await (tokenService as any).clearClientId(userIdFromAccessToken); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock, + ).toHaveBeenCalledWith(null); + }); + + it("should clear the client id from memory and disk for the global active user", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientId]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await (tokenService as any).clearClientId(); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock, + ).toHaveBeenCalledWith(null); + }); + }); + }); + + describe("Client Secret methods", () => { + const clientSecret = "clientSecret"; + + describe("setClientSecret", () => { + it("should throw an error if no user id is provided and there is no active user in global state", async () => { + // Act + // note: don't await here because we want to test the error + const result = tokenService.setClientSecret(clientSecret, VaultTimeoutAction.Lock, null); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot save client secret."); + }); + + describe("Memory storage tests", () => { + it("should set the client secret in memory when there is an active user in global state", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await tokenService.setClientSecret( + clientSecret, + memoryVaultTimeoutAction, + memoryVaultTimeout, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(clientSecret); + }); + + it("should set the client secret in memory for the specified user id", async () => { + // Act + await tokenService.setClientSecret( + clientSecret, + memoryVaultTimeoutAction, + memoryVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(clientSecret); + }); + }); + + describe("Disk storage tests", () => { + it("should set the client secret in disk when there is an active user in global state", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await tokenService.setClientSecret( + clientSecret, + diskVaultTimeoutAction, + diskVaultTimeout, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .nextMock, + ).toHaveBeenCalledWith(clientSecret); + }); + + it("should set the client secret in disk for the specified user id", async () => { + // Act + await tokenService.setClientSecret( + clientSecret, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .nextMock, + ).toHaveBeenCalledWith(clientSecret); + }); + }); + }); + + describe("getClientSecret", () => { + it("should return undefined if no user id is provided and there is no active user in global state", async () => { + // Act + const result = await tokenService.getClientSecret(); + // Assert + expect(result).toBeUndefined(); + }); + + it("should return null if no client secret is found in memory or disk", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientSecret(); + // Assert + expect(result).toBeNull(); + }); + + describe("Memory storage tests", () => { + it("should get the client secret from memory with no user id specified (uses global active user)", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientSecret(); + + // Assert + expect(result).toEqual(clientSecret); + }); + + it("should get the client secret from memory for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Act + const result = await tokenService.getClientSecret(userIdFromAccessToken); + // Assert + expect(result).toEqual(clientSecret); + }); + }); + + describe("Disk storage tests", () => { + it("should get the client secret from disk with no user id specified", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientSecret(); + // Assert + expect(result).toEqual(clientSecret); + }); + + it("should get the client secret from disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // Act + const result = await tokenService.getClientSecret(userIdFromAccessToken); + // Assert + expect(result).toEqual(clientSecret); + }); + }); + }); + + describe("clearClientSecret", () => { + it("should throw an error if no user id is provided and there is no active user in global state", async () => { + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).clearClientSecret(); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot clear client secret."); + }); + + it("should clear the client secret from memory and disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // Act + await (tokenService as any).clearClientSecret(userIdFromAccessToken); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .nextMock, + ).toHaveBeenCalledWith(null); + }); + + it("should clear the client secret from memory and disk for the global active user", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await (tokenService as any).clearClientSecret(); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .nextMock, + ).toHaveBeenCalledWith(null); + }); + }); + }); + + describe("setTokens", () => { + it("should call to set all passed in tokens after deriving user id from the access token", async () => { + // Arrange + const refreshToken = "refreshToken"; + // specific vault timeout actions and vault timeouts don't change this test so values don't matter. + const vaultTimeoutAction = VaultTimeoutAction.Lock; + const vaultTimeout = 30; + const clientId = "clientId"; + const clientSecret = "clientSecret"; + + (tokenService as any)._setAccessToken = jest.fn(); + // any hack allows for mocking private method. + (tokenService as any).setRefreshToken = jest.fn(); + tokenService.setClientId = jest.fn(); + tokenService.setClientSecret = jest.fn(); + + // Act + // Note: passing a valid access token so that a valid user id can be determined from the access token + await tokenService.setTokens(accessTokenJwt, refreshToken, vaultTimeoutAction, vaultTimeout, [ + clientId, + clientSecret, + ]); + + // Assert + expect((tokenService as any)._setAccessToken).toHaveBeenCalledWith( + accessTokenJwt, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + + // any hack allows for testing private methods + expect((tokenService as any).setRefreshToken).toHaveBeenCalledWith( + refreshToken, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + + expect(tokenService.setClientId).toHaveBeenCalledWith( + clientId, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + expect(tokenService.setClientSecret).toHaveBeenCalledWith( + clientSecret, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + }); + + it("should not try to set client id and client secret if they are not passed in", async () => { + // Arrange + const refreshToken = "refreshToken"; + const vaultTimeoutAction = VaultTimeoutAction.Lock; + const vaultTimeout = 30; + + (tokenService as any)._setAccessToken = jest.fn(); + (tokenService as any).setRefreshToken = jest.fn(); + tokenService.setClientId = jest.fn(); + tokenService.setClientSecret = jest.fn(); + + // Act + await tokenService.setTokens(accessTokenJwt, refreshToken, vaultTimeoutAction, vaultTimeout); + + // Assert + expect((tokenService as any)._setAccessToken).toHaveBeenCalledWith( + accessTokenJwt, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + + // any hack allows for testing private methods + expect((tokenService as any).setRefreshToken).toHaveBeenCalledWith( + refreshToken, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + + expect(tokenService.setClientId).not.toHaveBeenCalled(); + expect(tokenService.setClientSecret).not.toHaveBeenCalled(); + }); + + it("should throw an error if the access token is invalid", async () => { + // Arrange + const accessToken = "invalidToken"; + const refreshToken = "refreshToken"; + const vaultTimeoutAction = VaultTimeoutAction.Lock; + const vaultTimeout = 30; + + // Act + const result = tokenService.setTokens( + accessToken, + refreshToken, + vaultTimeoutAction, + vaultTimeout, + ); + + // Assert + await expect(result).rejects.toThrow("JWT must have 3 parts"); + }); + + it("should throw an error if the access token is missing", async () => { + // Arrange + const accessToken: string = null; + const refreshToken = "refreshToken"; + const vaultTimeoutAction = VaultTimeoutAction.Lock; + const vaultTimeout = 30; + + // Act + const result = tokenService.setTokens( + accessToken, + refreshToken, + vaultTimeoutAction, + vaultTimeout, + ); + + // Assert + await expect(result).rejects.toThrow("Access token and refresh token are required."); + }); + + it("should throw an error if the refresh token is missing", async () => { + // Arrange + const accessToken = "accessToken"; + const refreshToken: string = null; + const vaultTimeoutAction = VaultTimeoutAction.Lock; + const vaultTimeout = 30; + + // Act + const result = tokenService.setTokens( + accessToken, + refreshToken, + vaultTimeoutAction, + vaultTimeout, + ); + + // Assert + await expect(result).rejects.toThrow("Access token and refresh token are required."); + }); + }); + + describe("clearTokens", () => { + it("should call to clear all tokens for the specified user id", async () => { + // Arrange + const userId = "userId" as UserId; + + tokenService.clearAccessToken = jest.fn(); + (tokenService as any).clearRefreshToken = jest.fn(); + (tokenService as any).clearClientId = jest.fn(); + (tokenService as any).clearClientSecret = jest.fn(); + + // Act + + await tokenService.clearTokens(userId); + + // Assert + + expect(tokenService.clearAccessToken).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearRefreshToken).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearClientId).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearClientSecret).toHaveBeenCalledWith(userId); + }); + + it("should call to clear all tokens for the active user id", async () => { + // Arrange + const userId = "userId" as UserId; + + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).stateSubject.next(userId); + + tokenService.clearAccessToken = jest.fn(); + (tokenService as any).clearRefreshToken = jest.fn(); + (tokenService as any).clearClientId = jest.fn(); + (tokenService as any).clearClientSecret = jest.fn(); + + // Act + + await tokenService.clearTokens(); + + // Assert + + expect(tokenService.clearAccessToken).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearRefreshToken).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearClientId).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearClientSecret).toHaveBeenCalledWith(userId); + }); + + it("should not call to clear all tokens if no user id is provided and there is no active user in global state", async () => { + // Arrange + tokenService.clearAccessToken = jest.fn(); + (tokenService as any).clearRefreshToken = jest.fn(); + (tokenService as any).clearClientId = jest.fn(); + (tokenService as any).clearClientSecret = jest.fn(); + + // Act + + const result = tokenService.clearTokens(); + + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot clear tokens."); + }); + }); + + describe("Two Factor Token methods", () => { + describe("setTwoFactorToken", () => { + it("should set the email and two factor token when there hasn't been a previous record (initializing the record)", async () => { + // Arrange + const email = "testUser@email.com"; + const twoFactorToken = "twoFactorTokenForTestUser"; + // Act + await tokenService.setTwoFactorToken(email, twoFactorToken); + // Assert + expect( + globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock, + ).toHaveBeenCalledWith({ [email]: twoFactorToken }); + }); + + it("should set the email and two factor token when there is an initialized value already (updating the existing record)", async () => { + // Arrange + const email = "testUser@email.com"; + const twoFactorToken = "twoFactorTokenForTestUser"; + const initialTwoFactorTokenRecord: Record = { + otherUser: "otherUserTwoFactorToken", + }; + + globalStateProvider + .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) + .stateSubject.next(initialTwoFactorTokenRecord); + + // Act + await tokenService.setTwoFactorToken(email, twoFactorToken); + + // Assert + expect( + globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock, + ).toHaveBeenCalledWith({ [email]: twoFactorToken, ...initialTwoFactorTokenRecord }); + }); + }); + + describe("getTwoFactorToken", () => { + it("should return the two factor token for the given email", async () => { + // Arrange + const email = "testUser"; + const twoFactorToken = "twoFactorTokenForTestUser"; + const initialTwoFactorTokenRecord: Record = { + [email]: twoFactorToken, + }; + + globalStateProvider + .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) + .stateSubject.next(initialTwoFactorTokenRecord); + + // Act + const result = await tokenService.getTwoFactorToken(email); + + // Assert + expect(result).toEqual(twoFactorToken); + }); + + it("should not return the two factor token for an email that doesn't exist", async () => { + // Arrange + const email = "testUser"; + const initialTwoFactorTokenRecord: Record = { + otherUser: "twoFactorTokenForOtherUser", + }; + + globalStateProvider + .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) + .stateSubject.next(initialTwoFactorTokenRecord); + + // Act + const result = await tokenService.getTwoFactorToken(email); + + // Assert + expect(result).toEqual(undefined); + }); + + it("should return null if there is no two factor token record", async () => { + // Arrange + globalStateProvider + .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) + .stateSubject.next(null); + + // Act + const result = await tokenService.getTwoFactorToken("testUser"); + + // Assert + expect(result).toEqual(null); + }); + }); + + describe("clearTwoFactorToken", () => { + it("should clear the two factor token for the given email when a record exists", async () => { + // Arrange + const email = "testUser"; + const twoFactorToken = "twoFactorTokenForTestUser"; + const initialTwoFactorTokenRecord: Record = { + [email]: twoFactorToken, + }; + + globalStateProvider + .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) + .stateSubject.next(initialTwoFactorTokenRecord); + + // Act + await tokenService.clearTwoFactorToken(email); + + // Assert + expect( + globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock, + ).toHaveBeenCalledWith({}); + }); + + it("should initialize the record if it doesn't exist and delete the value", async () => { + // Arrange + const email = "testUser"; + + // Act + await tokenService.clearTwoFactorToken(email); + + // Assert + expect( + globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock, + ).toHaveBeenCalledWith({}); + }); + }); + }); + + // Helpers + function createTokenService(supportsSecureStorage: boolean) { + return new TokenService( + singleUserStateProvider, + globalStateProvider, + supportsSecureStorage, + secureStorageService, + ); + } +}); diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index b112c7b57d6..4e9722614ed 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -1,125 +1,629 @@ -import { StateService } from "../../platform/abstractions/state.service"; -import { Utils } from "../../platform/misc/utils"; +import { firstValueFrom } from "rxjs"; + +import { decodeJwtTokenToJson } from "@bitwarden/auth/common"; + +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { AbstractStorageService } from "../../platform/abstractions/storage.service"; +import { StorageLocation } from "../../platform/enums"; +import { StorageOptions } from "../../platform/models/domain/storage-options"; +import { + GlobalState, + GlobalStateProvider, + KeyDefinition, + SingleUserStateProvider, +} from "../../platform/state"; +import { UserId } from "../../types/guid"; import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service"; -import { IdentityTokenResponse } from "../models/response/identity-token.response"; + +import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service"; +import { + ACCESS_TOKEN_DISK, + ACCESS_TOKEN_MEMORY, + ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, + API_KEY_CLIENT_ID_DISK, + API_KEY_CLIENT_ID_MEMORY, + API_KEY_CLIENT_SECRET_DISK, + API_KEY_CLIENT_SECRET_MEMORY, + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + REFRESH_TOKEN_DISK, + REFRESH_TOKEN_MEMORY, + REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, +} from "./token.state"; + +export enum TokenStorageLocation { + Disk = "disk", + SecureStorage = "secureStorage", + Memory = "memory", +} + +/** + * Type representing the structure of a standard Bitwarden decoded access token. + * src: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 + * Note: all claims are technically optional so we must verify their existence before using them. + * Note 2: NumericDate is a number representing a date in seconds since the Unix epoch. + */ +export type DecodedAccessToken = { + /** Issuer - the issuer of the token, typically the URL of the authentication server */ + iss?: string; + + /** Not Before - a timestamp defining when the token starts being valid */ + nbf?: number; + + /** Issued At - a timestamp of when the token was issued */ + iat?: number; + + /** Expiration Time - a NumericDate timestamp of when the token will expire */ + exp?: number; + + /** Scope - the scope of the access request, such as the permissions the token grants */ + scope?: string[]; + + /** Authentication Method Reference - the methods used in the authentication */ + amr?: string[]; + + /** Client ID - the identifier for the client that requested the token */ + client_id?: string; + + /** Subject - the unique identifier for the user */ + sub?: string; + + /** Authentication Time - a timestamp of when the user authentication occurred */ + auth_time?: number; + + /** Identity Provider - the system or service that authenticated the user */ + idp?: string; + + /** Premium - a boolean flag indicating whether the account is premium */ + premium?: boolean; + + /** Email - the user's email address */ + email?: string; + + /** Email Verified - a boolean flag indicating whether the user's email address has been verified */ + email_verified?: boolean; + + /** + * Security Stamp - a unique identifier which invalidates the access token if it changes in the db + * (typically after critical account changes like a password change) + */ + sstamp?: string; + + /** Name - the name of the user */ + name?: string; + + /** Organization Owners - a list of organization owner identifiers */ + orgowner?: string[]; + + /** Device - the identifier of the device used */ + device?: string; + + /** JWT ID - a unique identifier for the JWT */ + jti?: string; +}; export class TokenService implements TokenServiceAbstraction { - static decodeToken(token: string): Promise { - if (token == null) { - throw new Error("Token not provided."); - } + private readonly accessTokenSecureStorageKey: string = "_accessToken"; - const parts = token.split("."); - if (parts.length !== 3) { - throw new Error("JWT must have 3 parts"); - } + private readonly refreshTokenSecureStorageKey: string = "_refreshToken"; - const decoded = Utils.fromUrlB64ToUtf8(parts[1]); - if (decoded == null) { - throw new Error("Cannot decode the token"); - } + private emailTwoFactorTokenRecordGlobalState: GlobalState>; - const decodedToken = JSON.parse(decoded); - return decodedToken; + private activeUserIdGlobalState: GlobalState; + + constructor( + // Note: we cannot use ActiveStateProvider because if we ever want to inject + // this service into the AccountService, we will make a circular dependency + private singleUserStateProvider: SingleUserStateProvider, + private globalStateProvider: GlobalStateProvider, + private readonly platformSupportsSecureStorage: boolean, + private secureStorageService: AbstractStorageService, + ) { + this.initializeState(); } - constructor(private stateService: StateService) {} + private initializeState(): void { + this.emailTwoFactorTokenRecordGlobalState = this.globalStateProvider.get( + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + ); + + this.activeUserIdGlobalState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID); + } async setTokens( accessToken: string, refreshToken: string, - clientIdClientSecret: [string, string], - ): Promise { - await this.setToken(accessToken); - await this.setRefreshToken(refreshToken); + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + clientIdClientSecret?: [string, string], + ): Promise { + if (!accessToken || !refreshToken) { + throw new Error("Access token and refresh token are required."); + } + + // get user id the access token + const userId: UserId = await this.getUserIdFromAccessToken(accessToken); + + if (!userId) { + throw new Error("User id not found. Cannot set tokens."); + } + + await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId); + await this.setRefreshToken(refreshToken, vaultTimeoutAction, vaultTimeout, userId); if (clientIdClientSecret != null) { - await this.setClientId(clientIdClientSecret[0]); - await this.setClientSecret(clientIdClientSecret[1]); + await this.setClientId(clientIdClientSecret[0], vaultTimeoutAction, vaultTimeout, userId); + await this.setClientSecret(clientIdClientSecret[1], vaultTimeoutAction, vaultTimeout, userId); } } - async setClientId(clientId: string): Promise { - return await this.stateService.setApiKeyClientId(clientId); + /** + * Internal helper for set access token which always requires user id. + * This is useful because setTokens always will have a user id from the access token whereas + * the public setAccessToken method does not. + */ + private async _setAccessToken( + accessToken: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId: UserId, + ): Promise { + const storageLocation = await this.determineStorageLocation( + vaultTimeoutAction, + vaultTimeout, + true, + ); + + switch (storageLocation) { + case TokenStorageLocation.SecureStorage: + await this.saveStringToSecureStorage(userId, this.accessTokenSecureStorageKey, accessToken); + + // TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408 + // 2024-02-20: Remove access token from memory and disk so that we migrate to secure storage over time. + // Remove these 2 calls to remove the access token from memory and disk after 3 releases. + + await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null); + await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); + + // Set flag to indicate that the access token has been migrated to secure storage (don't remove this) + await this.setAccessTokenMigratedToSecureStorage(userId); + + return; + case TokenStorageLocation.Disk: + await this.singleUserStateProvider + .get(userId, ACCESS_TOKEN_DISK) + .update((_) => accessToken); + return; + case TokenStorageLocation.Memory: + await this.singleUserStateProvider + .get(userId, ACCESS_TOKEN_MEMORY) + .update((_) => accessToken); + return; + } } - async getClientId(): Promise { - return await this.stateService.getApiKeyClientId(); + async setAccessToken( + accessToken: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + ): Promise { + if (!accessToken) { + throw new Error("Access token is required."); + } + const userId: UserId = await this.getUserIdFromAccessToken(accessToken); + + // If we don't have a user id, we can't save the value + if (!userId) { + throw new Error("User id not found. Cannot save access token."); + } + + await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId); } - async setClientSecret(clientSecret: string): Promise { - return await this.stateService.setApiKeyClientSecret(clientSecret); + async clearAccessToken(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + // If we don't have a user id, we can't clear the value + if (!userId) { + throw new Error("User id not found. Cannot clear access token."); + } + + // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data. + // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout + // but we can simply clear all locations to avoid the need to require those parameters + + if (this.platformSupportsSecureStorage) { + await this.secureStorageService.remove( + `${userId}${this.accessTokenSecureStorageKey}`, + this.getSecureStorageOptions(userId), + ); + } + + // Platform doesn't support secure storage, so use state provider implementation + await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null); + await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); } - async getClientSecret(): Promise { - return await this.stateService.getApiKeyClientSecret(); + async getAccessToken(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + return undefined; + } + + const accessTokenMigratedToSecureStorage = + await this.getAccessTokenMigratedToSecureStorage(userId); + if (this.platformSupportsSecureStorage && accessTokenMigratedToSecureStorage) { + return await this.getStringFromSecureStorage(userId, this.accessTokenSecureStorageKey); + } + + // Try to get the access token from memory + const accessTokenMemory = await this.getStateValueByUserIdAndKeyDef( + userId, + ACCESS_TOKEN_MEMORY, + ); + + if (accessTokenMemory != null) { + return accessTokenMemory; + } + + // If memory is null, read from disk + return await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK); } - async setToken(token: string): Promise { - await this.stateService.setAccessToken(token); + private async getAccessTokenMigratedToSecureStorage(userId: UserId): Promise { + return await firstValueFrom( + this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$, + ); } - async getToken(): Promise { - return await this.stateService.getAccessToken(); + private async setAccessTokenMigratedToSecureStorage(userId: UserId): Promise { + await this.singleUserStateProvider + .get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .update((_) => true); } - async setRefreshToken(refreshToken: string): Promise { - return await this.stateService.setRefreshToken(refreshToken); + // Private because we only ever set the refresh token when also setting the access token + // and we need the user id from the access token to save to secure storage + private async setRefreshToken( + refreshToken: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId: UserId, + ): Promise { + // If we don't have a user id, we can't save the value + if (!userId) { + throw new Error("User id not found. Cannot save refresh token."); + } + + const storageLocation = await this.determineStorageLocation( + vaultTimeoutAction, + vaultTimeout, + true, + ); + + switch (storageLocation) { + case TokenStorageLocation.SecureStorage: + await this.saveStringToSecureStorage( + userId, + this.refreshTokenSecureStorageKey, + refreshToken, + ); + + // TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408 + // 2024-02-20: Remove refresh token from memory and disk so that we migrate to secure storage over time. + // Remove these 2 calls to remove the refresh token from memory and disk after 3 releases. + await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null); + await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null); + + // Set flag to indicate that the refresh token has been migrated to secure storage (don't remove this) + await this.setRefreshTokenMigratedToSecureStorage(userId); + + return; + + case TokenStorageLocation.Disk: + await this.singleUserStateProvider + .get(userId, REFRESH_TOKEN_DISK) + .update((_) => refreshToken); + return; + + case TokenStorageLocation.Memory: + await this.singleUserStateProvider + .get(userId, REFRESH_TOKEN_MEMORY) + .update((_) => refreshToken); + return; + } } - async getRefreshToken(): Promise { - return await this.stateService.getRefreshToken(); + async getRefreshToken(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + return undefined; + } + + const refreshTokenMigratedToSecureStorage = + await this.getRefreshTokenMigratedToSecureStorage(userId); + if (this.platformSupportsSecureStorage && refreshTokenMigratedToSecureStorage) { + return await this.getStringFromSecureStorage(userId, this.refreshTokenSecureStorageKey); + } + + // pre-secure storage migration: + // Always read memory first b/c faster + const refreshTokenMemory = await this.getStateValueByUserIdAndKeyDef( + userId, + REFRESH_TOKEN_MEMORY, + ); + + if (refreshTokenMemory != null) { + return refreshTokenMemory; + } + + // if memory is null, read from disk + const refreshTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, REFRESH_TOKEN_DISK); + + if (refreshTokenDisk != null) { + return refreshTokenDisk; + } + + return null; } - async setTwoFactorToken(tokenResponse: IdentityTokenResponse): Promise { - return await this.stateService.setTwoFactorToken(tokenResponse.twoFactorToken); + private async clearRefreshToken(userId: UserId): Promise { + // If we don't have a user id, we can't clear the value + if (!userId) { + throw new Error("User id not found. Cannot clear refresh token."); + } + + // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data. + // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout + // but we can simply clear all locations to avoid the need to require those parameters + + if (this.platformSupportsSecureStorage) { + await this.secureStorageService.remove( + `${userId}${this.refreshTokenSecureStorageKey}`, + this.getSecureStorageOptions(userId), + ); + } + + // Platform doesn't support secure storage, so use state provider implementation + await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null); + await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null); } - async getTwoFactorToken(): Promise { - return await this.stateService.getTwoFactorToken(); + private async getRefreshTokenMigratedToSecureStorage(userId: UserId): Promise { + return await firstValueFrom( + this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$, + ); } - async clearTwoFactorToken(): Promise { - return await this.stateService.setTwoFactorToken(null); + private async setRefreshTokenMigratedToSecureStorage(userId: UserId): Promise { + await this.singleUserStateProvider + .get(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .update((_) => true); } - async clearToken(userId?: string): Promise { - await this.stateService.setAccessToken(null, { userId: userId }); - await this.stateService.setRefreshToken(null, { userId: userId }); - await this.stateService.setApiKeyClientId(null, { userId: userId }); - await this.stateService.setApiKeyClientSecret(null, { userId: userId }); + async setClientId( + clientId: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId?: UserId, + ): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + // If we don't have a user id, we can't save the value + if (!userId) { + throw new Error("User id not found. Cannot save client id."); + } + + const storageLocation = await this.determineStorageLocation( + vaultTimeoutAction, + vaultTimeout, + false, + ); + + if (storageLocation === TokenStorageLocation.Disk) { + await this.singleUserStateProvider + .get(userId, API_KEY_CLIENT_ID_DISK) + .update((_) => clientId); + } else if (storageLocation === TokenStorageLocation.Memory) { + await this.singleUserStateProvider + .get(userId, API_KEY_CLIENT_ID_MEMORY) + .update((_) => clientId); + } + } + + async getClientId(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + return undefined; + } + + // Always read memory first b/c faster + const apiKeyClientIdMemory = await this.getStateValueByUserIdAndKeyDef( + userId, + API_KEY_CLIENT_ID_MEMORY, + ); + + if (apiKeyClientIdMemory != null) { + return apiKeyClientIdMemory; + } + + // if memory is null, read from disk + return await this.getStateValueByUserIdAndKeyDef(userId, API_KEY_CLIENT_ID_DISK); + } + + private async clearClientId(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + // If we don't have a user id, we can't clear the value + if (!userId) { + throw new Error("User id not found. Cannot clear client id."); + } + + // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data. + // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout + // but we can simply clear both locations to avoid the need to require those parameters + + // Platform doesn't support secure storage, so use state provider implementation + await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_ID_MEMORY).update((_) => null); + await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_ID_DISK).update((_) => null); + } + + async setClientSecret( + clientSecret: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId?: UserId, + ): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + throw new Error("User id not found. Cannot save client secret."); + } + + const storageLocation = await this.determineStorageLocation( + vaultTimeoutAction, + vaultTimeout, + false, + ); + + if (storageLocation === TokenStorageLocation.Disk) { + await this.singleUserStateProvider + .get(userId, API_KEY_CLIENT_SECRET_DISK) + .update((_) => clientSecret); + } else if (storageLocation === TokenStorageLocation.Memory) { + await this.singleUserStateProvider + .get(userId, API_KEY_CLIENT_SECRET_MEMORY) + .update((_) => clientSecret); + } + } + + async getClientSecret(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + return undefined; + } + + // Always read memory first b/c faster + const apiKeyClientSecretMemory = await this.getStateValueByUserIdAndKeyDef( + userId, + API_KEY_CLIENT_SECRET_MEMORY, + ); + + if (apiKeyClientSecretMemory != null) { + return apiKeyClientSecretMemory; + } + + // if memory is null, read from disk + return await this.getStateValueByUserIdAndKeyDef(userId, API_KEY_CLIENT_SECRET_DISK); + } + + private async clearClientSecret(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + // If we don't have a user id, we can't clear the value + if (!userId) { + throw new Error("User id not found. Cannot clear client secret."); + } + + // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data. + // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout + // but we can simply clear both locations to avoid the need to require those parameters + + // Platform doesn't support secure storage, so use state provider implementation + await this.singleUserStateProvider + .get(userId, API_KEY_CLIENT_SECRET_MEMORY) + .update((_) => null); + await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_SECRET_DISK).update((_) => null); + } + + async setTwoFactorToken(email: string, twoFactorToken: string): Promise { + await this.emailTwoFactorTokenRecordGlobalState.update((emailTwoFactorTokenRecord) => { + emailTwoFactorTokenRecord ??= {}; + + emailTwoFactorTokenRecord[email] = twoFactorToken; + return emailTwoFactorTokenRecord; + }); + } + + async getTwoFactorToken(email: string): Promise { + const emailTwoFactorTokenRecord: Record = await firstValueFrom( + this.emailTwoFactorTokenRecordGlobalState.state$, + ); + + if (!emailTwoFactorTokenRecord) { + return null; + } + + return emailTwoFactorTokenRecord[email]; + } + + async clearTwoFactorToken(email: string): Promise { + await this.emailTwoFactorTokenRecordGlobalState.update((emailTwoFactorTokenRecord) => { + emailTwoFactorTokenRecord ??= {}; + delete emailTwoFactorTokenRecord[email]; + return emailTwoFactorTokenRecord; + }); + } + + async clearTokens(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + throw new Error("User id not found. Cannot clear tokens."); + } + + await Promise.all([ + this.clearAccessToken(userId), + this.clearRefreshToken(userId), + this.clearClientId(userId), + this.clearClientSecret(userId), + ]); } // jwthelper methods // ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js - async decodeToken(token?: string): Promise { - token = token ?? (await this.stateService.getAccessToken()); + async decodeAccessToken(token?: string): Promise { + token = token ?? (await this.getAccessToken()); if (token == null) { - throw new Error("Token not found."); + throw new Error("Access token not found."); } - return TokenService.decodeToken(token); + return decodeJwtTokenToJson(token) as DecodedAccessToken; } - async getTokenExpirationDate(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.exp === "undefined") { + // TODO: PM-6678- tech debt - consider consolidating the return types of all these access + // token data retrieval methods to return null if something goes wrong instead of throwing an error. + + async getTokenExpirationDate(): Promise { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + // per RFC, exp claim is optional but if it exists, it should be a number + if (!decoded || typeof decoded.exp !== "number") { return null; } - const d = new Date(0); // The 0 here is the key, which sets the date to the epoch - d.setUTCSeconds(decoded.exp); - return d; + // The 0 in Date(0) is the key; it sets the date to the epoch + const expirationDate = new Date(0); + expirationDate.setUTCSeconds(decoded.exp); + return expirationDate; } async tokenSecondsRemaining(offsetSeconds = 0): Promise { - const d = await this.getTokenExpirationDate(); - if (d == null) { + const date = await this.getTokenExpirationDate(); + if (date == null) { return 0; } - const msRemaining = d.valueOf() - (new Date().valueOf() + offsetSeconds * 1000); + const msRemaining = date.valueOf() - (new Date().valueOf() + offsetSeconds * 1000); return Math.round(msRemaining / 1000); } @@ -128,54 +632,159 @@ export class TokenService implements TokenServiceAbstraction { return sRemaining < 60 * minutes; } - async getUserId(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.sub === "undefined") { + async getUserId(): Promise { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.sub !== "string") { throw new Error("No user id found"); } - return decoded.sub as string; + return decoded.sub as UserId; + } + + private async getUserIdFromAccessToken(accessToken: string): Promise { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(accessToken); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.sub !== "string") { + throw new Error("No user id found"); + } + + return decoded.sub as UserId; } async getEmail(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.email === "undefined") { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.email !== "string") { throw new Error("No email found"); } - return decoded.email as string; + return decoded.email; } async getEmailVerified(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.email_verified === "undefined") { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.email_verified !== "boolean") { throw new Error("No email verification found"); } - return decoded.email_verified as boolean; + return decoded.email_verified; } async getName(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.name === "undefined") { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.name !== "string") { return null; } - return decoded.name as string; + return decoded.name; } async getIssuer(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.iss === "undefined") { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.iss !== "string") { throw new Error("No issuer found"); } - return decoded.iss as string; + return decoded.iss; } async getIsExternal(): Promise { - const decoded = await this.decodeToken(); + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } return Array.isArray(decoded.amr) && decoded.amr.includes("external"); } + + private async getStateValueByUserIdAndKeyDef( + userId: UserId, + storageLocation: KeyDefinition, + ): Promise { + // read from single user state provider + return await firstValueFrom(this.singleUserStateProvider.get(userId, storageLocation).state$); + } + + private async determineStorageLocation( + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + useSecureStorage: boolean, + ): Promise { + if (vaultTimeoutAction === VaultTimeoutAction.LogOut && vaultTimeout != null) { + return TokenStorageLocation.Memory; + } else { + if (useSecureStorage && this.platformSupportsSecureStorage) { + return TokenStorageLocation.SecureStorage; + } + + return TokenStorageLocation.Disk; + } + } + + private async saveStringToSecureStorage( + userId: UserId, + storageKey: string, + value: string, + ): Promise { + await this.secureStorageService.save( + `${userId}${storageKey}`, + value, + this.getSecureStorageOptions(userId), + ); + } + + private async getStringFromSecureStorage( + userId: UserId, + storageKey: string, + ): Promise { + // If we have a user ID, read from secure storage. + return await this.secureStorageService.get( + `${userId}${storageKey}`, + this.getSecureStorageOptions(userId), + ); + } + + private getSecureStorageOptions(userId: UserId): StorageOptions { + return { + storageLocation: StorageLocation.Disk, + useSecureStorage: true, + userId: userId, + }; + } } diff --git a/libs/common/src/auth/services/token.state.spec.ts b/libs/common/src/auth/services/token.state.spec.ts new file mode 100644 index 00000000000..f4089a73fb4 --- /dev/null +++ b/libs/common/src/auth/services/token.state.spec.ts @@ -0,0 +1,64 @@ +import { KeyDefinition } from "../../platform/state"; + +import { + ACCESS_TOKEN_DISK, + ACCESS_TOKEN_MEMORY, + ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, + API_KEY_CLIENT_ID_DISK, + API_KEY_CLIENT_ID_MEMORY, + API_KEY_CLIENT_SECRET_DISK, + API_KEY_CLIENT_SECRET_MEMORY, + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + REFRESH_TOKEN_DISK, + REFRESH_TOKEN_MEMORY, + REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, +} from "./token.state"; + +describe.each([ + [ACCESS_TOKEN_DISK, "accessTokenDisk"], + [ACCESS_TOKEN_MEMORY, "accessTokenMemory"], + [ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, true], + [REFRESH_TOKEN_DISK, "refreshTokenDisk"], + [REFRESH_TOKEN_MEMORY, "refreshTokenMemory"], + [REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, true], + [EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, { user: "token" }], + [API_KEY_CLIENT_ID_DISK, "apiKeyClientIdDisk"], + [API_KEY_CLIENT_ID_MEMORY, "apiKeyClientIdMemory"], + [API_KEY_CLIENT_SECRET_DISK, "apiKeyClientSecretDisk"], + [API_KEY_CLIENT_SECRET_MEMORY, "apiKeyClientSecretMemory"], +])( + "deserializes state key definitions", + ( + keyDefinition: + | KeyDefinition + | KeyDefinition + | KeyDefinition>, + state: string | boolean | Record, + ) => { + function getTypeDescription(value: any): string { + if (isRecord(value)) { + return "Record"; + } else if (Array.isArray(value)) { + return "array"; + } else if (value === null) { + return "null"; + } + + // Fallback for primitive types + return typeof value; + } + + function isRecord(value: any): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + function testDeserialization(keyDefinition: KeyDefinition, state: T) { + const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state))); + expect(deserialized).toEqual(state); + } + + it(`should deserialize state for KeyDefinition<${getTypeDescription(state)}>: "${keyDefinition.key}"`, () => { + testDeserialization(keyDefinition, state); + }); + }, +); diff --git a/libs/common/src/auth/services/token.state.ts b/libs/common/src/auth/services/token.state.ts new file mode 100644 index 00000000000..022f56f7aa5 --- /dev/null +++ b/libs/common/src/auth/services/token.state.ts @@ -0,0 +1,65 @@ +import { KeyDefinition, TOKEN_DISK, TOKEN_DISK_LOCAL, TOKEN_MEMORY } from "../../platform/state"; + +export const ACCESS_TOKEN_DISK = new KeyDefinition(TOKEN_DISK, "accessToken", { + deserializer: (accessToken) => accessToken, +}); + +export const ACCESS_TOKEN_MEMORY = new KeyDefinition(TOKEN_MEMORY, "accessToken", { + deserializer: (accessToken) => accessToken, +}); + +export const ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE = new KeyDefinition( + TOKEN_DISK, + "accessTokenMigratedToSecureStorage", + { + deserializer: (accessTokenMigratedToSecureStorage) => accessTokenMigratedToSecureStorage, + }, +); + +export const REFRESH_TOKEN_DISK = new KeyDefinition(TOKEN_DISK, "refreshToken", { + deserializer: (refreshToken) => refreshToken, +}); + +export const REFRESH_TOKEN_MEMORY = new KeyDefinition(TOKEN_MEMORY, "refreshToken", { + deserializer: (refreshToken) => refreshToken, +}); + +export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE = new KeyDefinition( + TOKEN_DISK, + "refreshTokenMigratedToSecureStorage", + { + deserializer: (refreshTokenMigratedToSecureStorage) => refreshTokenMigratedToSecureStorage, + }, +); + +export const EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL = KeyDefinition.record( + TOKEN_DISK_LOCAL, + "emailTwoFactorTokenRecord", + { + deserializer: (emailTwoFactorTokenRecord) => emailTwoFactorTokenRecord, + }, +); + +export const API_KEY_CLIENT_ID_DISK = new KeyDefinition(TOKEN_DISK, "apiKeyClientId", { + deserializer: (apiKeyClientId) => apiKeyClientId, +}); + +export const API_KEY_CLIENT_ID_MEMORY = new KeyDefinition(TOKEN_MEMORY, "apiKeyClientId", { + deserializer: (apiKeyClientId) => apiKeyClientId, +}); + +export const API_KEY_CLIENT_SECRET_DISK = new KeyDefinition( + TOKEN_DISK, + "apiKeyClientSecret", + { + deserializer: (apiKeyClientSecret) => apiKeyClientSecret, + }, +); + +export const API_KEY_CLIENT_SECRET_MEMORY = new KeyDefinition( + TOKEN_MEMORY, + "apiKeyClientSecret", + { + deserializer: (apiKeyClientSecret) => apiKeyClientSecret, + }, +); diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 3fc65e4acf1..938720daaae 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -52,16 +52,11 @@ export abstract class StateService { clean: (options?: StorageOptions) => Promise; init: (initOptions?: InitOptions) => Promise; - getAccessToken: (options?: StorageOptions) => Promise; - setAccessToken: (value: string, options?: StorageOptions) => Promise; getAddEditCipherInfo: (options?: StorageOptions) => Promise; setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise; getAlwaysShowDock: (options?: StorageOptions) => Promise; setAlwaysShowDock: (value: boolean, options?: StorageOptions) => Promise; - getApiKeyClientId: (options?: StorageOptions) => Promise; - setApiKeyClientId: (value: string, options?: StorageOptions) => Promise; - getApiKeyClientSecret: (options?: StorageOptions) => Promise; - setApiKeyClientSecret: (value: string, options?: StorageOptions) => Promise; + getAutoConfirmFingerPrints: (options?: StorageOptions) => Promise; setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise; getBiometricFingerprintValidated: (options?: StorageOptions) => Promise; @@ -332,14 +327,10 @@ export abstract class StateService { * Sets the user's Pin, encrypted by the user key */ setProtectedPin: (value: string, options?: StorageOptions) => Promise; - getRefreshToken: (options?: StorageOptions) => Promise; - setRefreshToken: (value: string, options?: StorageOptions) => Promise; getRememberedEmail: (options?: StorageOptions) => Promise; setRememberedEmail: (value: string, options?: StorageOptions) => Promise; getSecurityStamp: (options?: StorageOptions) => Promise; setSecurityStamp: (value: string, options?: StorageOptions) => Promise; - getTwoFactorToken: (options?: StorageOptions) => Promise; - setTwoFactorToken: (value: string, options?: StorageOptions) => Promise; getUserId: (options?: StorageOptions) => Promise; getUsesKeyConnector: (options?: StorageOptions) => Promise; setUsesKeyConnector: (value: boolean, options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 0c85307032d..edb8f87d254 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -112,7 +112,6 @@ export class AccountKeys { masterKeyEncryptedUserKey?: string; deviceKey?: ReturnType; publicKey?: Uint8Array; - apiKeyClientSecret?: string; /** @deprecated July 2023, left for migration purposes*/ cryptoMasterKey?: SymmetricCryptoKey; @@ -167,7 +166,6 @@ export class AccountKeys { } export class AccountProfile { - apiKeyClientId?: string; convertAccountToKeyConnector?: boolean; name?: string; email?: string; @@ -233,8 +231,6 @@ export class AccountSettings { } export class AccountTokens { - accessToken?: string; - refreshToken?: string; securityStamp?: string; static fromJSON(obj: Jsonify): AccountTokens { diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 08c5350d06d..0ccd405dd13 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -3,12 +3,12 @@ import { Jsonify, JsonValue } from "type-fest"; import { OrganizationData } from "../../admin-console/models/data/organization.data"; import { AccountService } from "../../auth/abstractions/account.service"; +import { TokenService } from "../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; -import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { EventData } from "../../models/data/event.data"; import { WindowState } from "../../models/domain/window-state"; import { GeneratorOptions } from "../../tools/generator/generator-options"; @@ -100,6 +100,7 @@ export class StateService< protected stateFactory: StateFactory, protected accountService: AccountService, protected environmentService: EnvironmentService, + protected tokenService: TokenService, private migrationRunner: MigrationRunner, protected useAccountCache: boolean = true, ) { @@ -190,7 +191,7 @@ export class StateService< // TODO: Temporary update to avoid routing all account status changes through account service for now. // The determination of state should be handled by the various services that control those values. - const token = await this.getAccessToken({ userId: userId }); + const token = await this.tokenService.getAccessToken(userId as UserId); const autoKey = await this.getUserKeyAutoUnlock({ userId: userId }); const accountStatus = token == null @@ -255,18 +256,6 @@ export class StateService< return currentUser as UserId; } - async getAccessToken(options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - return (await this.getAccount(options))?.tokens?.accessToken; - } - - async setAccessToken(value: string, options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - const account = await this.getAccount(options); - account.tokens.accessToken = value; - await this.saveAccount(account, options); - } - async getAddEditCipherInfo(options?: StorageOptions): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()), @@ -313,30 +302,6 @@ export class StateService< ); } - async getApiKeyClientId(options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - return (await this.getAccount(options))?.profile?.apiKeyClientId; - } - - async setApiKeyClientId(value: string, options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - const account = await this.getAccount(options); - account.profile.apiKeyClientId = value; - await this.saveAccount(account, options); - } - - async getApiKeyClientSecret(options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - return (await this.getAccount(options))?.keys?.apiKeyClientSecret; - } - - async setApiKeyClientSecret(value: string, options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - const account = await this.getAccount(options); - account.keys.apiKeyClientSecret = value; - await this.saveAccount(account, options); - } - async getAutoConfirmFingerPrints(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) @@ -1356,7 +1321,10 @@ export class StateService< } async getIsAuthenticated(options?: StorageOptions): Promise { - return (await this.getAccessToken(options)) != null && (await this.getUserId(options)) != null; + return ( + (await this.tokenService.getAccessToken(options?.userId as UserId)) != null && + (await this.getUserId(options)) != null + ); } async getKdfConfig(options?: StorageOptions): Promise { @@ -1672,18 +1640,6 @@ export class StateService< ); } - async getRefreshToken(options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - return (await this.getAccount(options))?.tokens?.refreshToken; - } - - async setRefreshToken(value: string, options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - const account = await this.getAccount(options); - account.tokens.refreshToken = value; - await this.saveAccount(account, options); - } - async getRememberedEmail(options?: StorageOptions): Promise { return ( await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) @@ -1718,23 +1674,6 @@ export class StateService< ); } - async getTwoFactorToken(options?: StorageOptions): Promise { - return ( - await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) - )?.twoFactorToken; - } - - async setTwoFactorToken(value: string, options?: StorageOptions): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - globals.twoFactorToken = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - async getUserId(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) @@ -2041,15 +1980,6 @@ export class StateService< await this.storageService.remove(keys.tempAccountSettings); } - if ( - account.settings.vaultTimeoutAction === VaultTimeoutAction.LogOut && - account.settings.vaultTimeout != null - ) { - account.tokens.accessToken = null; - account.tokens.refreshToken = null; - account.profile.apiKeyClientId = null; - account.keys.apiKeyClientSecret = null; - } await this.saveAccount( account, this.reconcileOptions( @@ -2250,7 +2180,7 @@ export class StateService< } protected async deAuthenticateAccount(userId: string): Promise { - await this.setAccessToken(null, { userId: userId }); + await this.tokenService.clearAccessToken(userId as UserId); await this.setLastActive(null, { userId: userId }); await this.updateState(async (state) => { state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId); @@ -2293,16 +2223,6 @@ export class StateService< return newActiveUser; } - private async getTimeoutBasedStorageOptions(options?: StorageOptions): Promise { - const timeoutAction = await this.getVaultTimeoutAction({ userId: options?.userId }); - const timeout = await this.getVaultTimeout({ userId: options?.userId }); - const defaultOptions = - timeoutAction === VaultTimeoutAction.LogOut && timeout != null - ? await this.defaultInMemoryOptions() - : await this.defaultOnDiskOptions(); - return this.reconcileOptions(options, defaultOptions); - } - protected async saveSecureStorageKey( key: string, value: T, diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 86b8dd051cb..34b6bb097f0 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -28,6 +28,11 @@ export const PROVIDERS_DISK = new StateDefinition("providers", "disk"); export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" }); export const SSO_DISK = new StateDefinition("ssoLogin", "disk"); +export const TOKEN_DISK = new StateDefinition("token", "disk"); +export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", { + web: "disk-local", +}); +export const TOKEN_MEMORY = new StateDefinition("token", "memory"); export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory"); // Autofill diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 336191f3abb..869d45ebff1 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -93,6 +93,7 @@ import { SubscriptionResponse } from "../billing/models/response/subscription.re import { TaxInfoResponse } from "../billing/models/response/tax-info.response"; import { TaxRateResponse } from "../billing/models/response/tax-rate.response"; import { DeviceType } from "../enums"; +import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; import { CollectionBulkDeleteRequest } from "../models/request/collection-bulk-delete.request"; import { DeleteRecoverRequest } from "../models/request/delete-recover.request"; import { EventRequest } from "../models/request/event.request"; @@ -116,6 +117,7 @@ import { UserKeyResponse } from "../models/response/user-key.response"; import { AppIdService } from "../platform/abstractions/app-id.service"; import { EnvironmentService } from "../platform/abstractions/environment.service"; import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { StateService } from "../platform/abstractions/state.service"; import { Utils } from "../platform/misc/utils"; import { AttachmentRequest } from "../vault/models/request/attachment.request"; import { CipherBulkDeleteRequest } from "../vault/models/request/cipher-bulk-delete.request"; @@ -154,6 +156,7 @@ export class ApiService implements ApiServiceAbstraction { private platformUtilsService: PlatformUtilsService, private environmentService: EnvironmentService, private appIdService: AppIdService, + private stateService: StateService, private logoutCallback: (expired: boolean) => Promise, private customUserAgent: string = null, ) { @@ -224,7 +227,6 @@ export class ApiService implements ApiServiceAbstraction { responseJson.TwoFactorProviders2 && Object.keys(responseJson.TwoFactorProviders2).length ) { - await this.tokenService.clearTwoFactorToken(); return new IdentityTwoFactorResponse(responseJson); } else if ( response.status === 400 && @@ -1578,10 +1580,10 @@ export class ApiService implements ApiServiceAbstraction { // Helpers async getActiveBearerToken(): Promise { - let accessToken = await this.tokenService.getToken(); + let accessToken = await this.tokenService.getAccessToken(); if (await this.tokenService.tokenNeedsRefresh()) { await this.doAuthRefresh(); - accessToken = await this.tokenService.getToken(); + accessToken = await this.tokenService.getAccessToken(); } return accessToken; } @@ -1749,7 +1751,7 @@ export class ApiService implements ApiServiceAbstraction { headers.set("User-Agent", this.customUserAgent); } - const decodedToken = await this.tokenService.decodeToken(); + const decodedToken = await this.tokenService.decodeAccessToken(); const response = await this.fetch( new Request(this.environmentService.getIdentityUrl() + "/connect/token", { body: this.qsStringify({ @@ -1767,10 +1769,15 @@ export class ApiService implements ApiServiceAbstraction { if (response.status === 200) { const responseJson = await response.json(); const tokenResponse = new IdentityTokenResponse(responseJson); + + const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction(); + const vaultTimeout = await this.stateService.getVaultTimeout(); + await this.tokenService.setTokens( tokenResponse.accessToken, tokenResponse.refreshToken, - null, + vaultTimeoutAction as VaultTimeoutAction, + vaultTimeout, ); } else { const error = await this.handleError(response, true, true); @@ -1796,7 +1803,14 @@ export class ApiService implements ApiServiceAbstraction { throw new Error("Invalid response received when refreshing api token"); } - await this.tokenService.setToken(response.accessToken); + const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction(); + const vaultTimeout = await this.stateService.getVaultTimeout(); + + await this.tokenService.setAccessToken( + response.accessToken, + vaultTimeoutAction as VaultTimeoutAction, + vaultTimeout, + ); } async send( diff --git a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts index 0d0eb508cb2..e8897d82b7d 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts @@ -29,7 +29,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA async setVaultTimeoutOptions(timeout: number, action: VaultTimeoutAction): Promise { // We swap these tokens from being on disk for lock actions, and in memory for logout actions // Get them here to set them to their new location after changing the timeout action and clearing if needed - const token = await this.tokenService.getToken(); + const accessToken = await this.tokenService.getAccessToken(); const refreshToken = await this.tokenService.getRefreshToken(); const clientId = await this.tokenService.getClientId(); const clientSecret = await this.tokenService.getClientSecret(); @@ -37,21 +37,22 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA await this.stateService.setVaultTimeout(timeout); const currentAction = await this.stateService.getVaultTimeoutAction(); + if ( (timeout != null || timeout === 0) && action === VaultTimeoutAction.LogOut && action !== currentAction ) { // if we have a vault timeout and the action is log out, reset tokens - await this.tokenService.clearToken(); + await this.tokenService.clearTokens(); } await this.stateService.setVaultTimeoutAction(action); - await this.tokenService.setToken(token); - await this.tokenService.setRefreshToken(refreshToken); - await this.tokenService.setClientId(clientId); - await this.tokenService.setClientSecret(clientSecret); + await this.tokenService.setTokens(accessToken, refreshToken, action, timeout, [ + clientId, + clientSecret, + ]); await this.cryptoService.refreshAdditionalKeys(); } diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 77a35ccb871..798af382209 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -24,7 +24,6 @@ import { RevertLastSyncMigrator } from "./migrations/26-revert-move-last-sync-to import { BadgeSettingsMigrator } from "./migrations/27-move-badge-settings-to-state-providers"; import { MoveBiometricUnlockToStateProviders } from "./migrations/28-move-biometric-unlock-to-state-providers"; import { UserNotificationSettingsKeyMigrator } from "./migrations/29-move-user-notification-settings-to-state-provider"; -import { FixPremiumMigrator } from "./migrations/3-fix-premium"; import { PolicyMigrator } from "./migrations/30-move-policy-state-to-state-provider"; import { EnableContextMenuMigrator } from "./migrations/31-move-enable-context-menu-to-autofill-settings-state-provider"; import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language"; @@ -33,6 +32,7 @@ import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-to- import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to-state-providers"; import { VaultSettingsKeyMigrator } from "./migrations/36-move-show-card-and-identity-to-state-provider"; import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state-providers"; +import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token-svc-to-state-provider"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; @@ -41,14 +41,13 @@ import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global"; import { MinVersionMigrator } from "./migrations/min-version"; -export const MIN_VERSION = 2; -export const CURRENT_VERSION = 37; +export const MIN_VERSION = 3; +export const CURRENT_VERSION = 38; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { return MigrationBuilder.create() .with(MinVersionMigrator) - .with(FixPremiumMigrator, 2, 3) .with(RemoveEverBeenUnlockedMigrator, 3, 4) .with(AddKeyTypeToOrgKeysMigrator, 4, 5) .with(RemoveLegacyEtmKeyMigrator, 5, 6) @@ -82,7 +81,8 @@ export function createMigrationBuilder() { .with(DomainSettingsMigrator, 33, 34) .with(MoveThemeToStateProviderMigrator, 34, 35) .with(VaultSettingsKeyMigrator, 35, 36) - .with(AvatarColorMigrator, 36, CURRENT_VERSION); + .with(AvatarColorMigrator, 36, 37) + .with(TokenServiceStateProviderMigrator, 37, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts b/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts deleted file mode 100644 index 1ef910d4569..00000000000 --- a/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { MockProxy } from "jest-mock-extended"; - -// eslint-disable-next-line import/no-restricted-paths -- Used for testing migration, which requires import -import { TokenService } from "../../auth/services/token.service"; -import { MigrationHelper } from "../migration-helper"; -import { mockMigrationHelper } from "../migration-helper.spec"; - -import { FixPremiumMigrator } from "./3-fix-premium"; - -function migrateExampleJSON() { - return { - global: { - stateVersion: 2, - otherStuff: "otherStuff1", - }, - authenticatedAccounts: [ - "c493ed01-4e08-4e88-abc7-332f380ca760", - "23e61a5f-2ece-4f5e-b499-f0bc489482a9", - ], - "c493ed01-4e08-4e88-abc7-332f380ca760": { - profile: { - otherStuff: "otherStuff2", - hasPremiumPersonally: null as boolean, - }, - tokens: { - otherStuff: "otherStuff3", - accessToken: "accessToken", - }, - otherStuff: "otherStuff4", - }, - "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { - profile: { - otherStuff: "otherStuff5", - hasPremiumPersonally: true, - }, - tokens: { - otherStuff: "otherStuff6", - accessToken: "accessToken", - }, - otherStuff: "otherStuff7", - }, - otherStuff: "otherStuff8", - }; -} - -jest.mock("../../auth/services/token.service", () => ({ - TokenService: { - decodeToken: jest.fn(), - }, -})); - -describe("FixPremiumMigrator", () => { - let helper: MockProxy; - let sut: FixPremiumMigrator; - const decodeTokenSpy = TokenService.decodeToken as jest.Mock; - - beforeEach(() => { - helper = mockMigrationHelper(migrateExampleJSON()); - sut = new FixPremiumMigrator(2, 3); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("migrate", () => { - it("should migrate hasPremiumPersonally", async () => { - decodeTokenSpy.mockResolvedValueOnce({ premium: true }); - await sut.migrate(helper); - - expect(helper.set).toHaveBeenCalledTimes(1); - expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { - profile: { - otherStuff: "otherStuff2", - hasPremiumPersonally: true, - }, - tokens: { - otherStuff: "otherStuff3", - accessToken: "accessToken", - }, - otherStuff: "otherStuff4", - }); - }); - - it("should not migrate if decode throws", async () => { - decodeTokenSpy.mockRejectedValueOnce(new Error("test")); - await sut.migrate(helper); - - expect(helper.set).not.toHaveBeenCalled(); - }); - - it("should not migrate if decode returns null", async () => { - decodeTokenSpy.mockResolvedValueOnce(null); - await sut.migrate(helper); - - expect(helper.set).not.toHaveBeenCalled(); - }); - }); - - describe("updateVersion", () => { - it("should update version", async () => { - await sut.updateVersion(helper, "up"); - - expect(helper.set).toHaveBeenCalledTimes(1); - expect(helper.set).toHaveBeenCalledWith("global", { - stateVersion: 3, - otherStuff: "otherStuff1", - }); - }); - }); -}); diff --git a/libs/common/src/state-migrations/migrations/3-fix-premium.ts b/libs/common/src/state-migrations/migrations/3-fix-premium.ts deleted file mode 100644 index b6c69a99168..00000000000 --- a/libs/common/src/state-migrations/migrations/3-fix-premium.ts +++ /dev/null @@ -1,48 +0,0 @@ -// eslint-disable-next-line import/no-restricted-paths -- Used for token decoding, which are valid for days. We want the latest -import { TokenService } from "../../auth/services/token.service"; -import { MigrationHelper } from "../migration-helper"; -import { Migrator, IRREVERSIBLE, Direction } from "../migrator"; - -type ExpectedAccountType = { - profile?: { hasPremiumPersonally?: boolean }; - tokens?: { accessToken?: string }; -}; - -export class FixPremiumMigrator extends Migrator<2, 3> { - async migrate(helper: MigrationHelper): Promise { - const accounts = await helper.getAccounts(); - - async function fixPremium(userId: string, account: ExpectedAccountType) { - if (account?.profile?.hasPremiumPersonally === null && account.tokens?.accessToken != null) { - let decodedToken: { premium: boolean }; - try { - decodedToken = await TokenService.decodeToken(account.tokens.accessToken); - } catch { - return; - } - - if (decodedToken?.premium == null) { - return; - } - - account.profile.hasPremiumPersonally = decodedToken?.premium; - return helper.set(userId, account); - } - } - - await Promise.all(accounts.map(({ userId, account }) => fixPremium(userId, account))); - } - - rollback(helper: MigrationHelper): Promise { - throw IRREVERSIBLE; - } - - // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version - // it is nested inside a global object. - override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { - const endVersion = direction === "up" ? this.toVersion : this.fromVersion; - helper.currentVersion = endVersion; - const global: Record = (await helper.get("global")) || {}; - await helper.set("global", { ...global, stateVersion: endVersion }); - } -} diff --git a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts new file mode 100644 index 00000000000..a5243c261a5 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts @@ -0,0 +1,258 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + ACCESS_TOKEN_DISK, + REFRESH_TOKEN_DISK, + API_KEY_CLIENT_ID_DISK, + API_KEY_CLIENT_SECRET_DISK, + TokenServiceStateProviderMigrator, +} from "./38-migrate-token-svc-to-state-provider"; + +// Represents data in state service pre-migration +function preMigrationJson() { + return { + global: { + twoFactorToken: "twoFactorToken", + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2", "user3"], + user1: { + tokens: { + accessToken: "accessToken", + refreshToken: "refreshToken", + otherStuff: "overStuff2", + }, + profile: { + apiKeyClientId: "apiKeyClientId", + email: "user1Email", + otherStuff: "overStuff3", + }, + keys: { + apiKeyClientSecret: "apiKeyClientSecret", + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }, + user2: { + tokens: { + // no tokens to migrate + otherStuff: "overStuff2", + }, + profile: { + // no apiKeyClientId to migrate + otherStuff: "overStuff3", + email: "user2Email", + }, + keys: { + // no apiKeyClientSecret to migrate + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + // User specific state provider data + // use pattern user_{userId}_{stateDefinitionName}_{keyDefinitionKey} for user data + + // User1 migrated data + user_user1_token_accessToken: "accessToken", + user_user1_token_refreshToken: "refreshToken", + user_user1_token_apiKeyClientId: "apiKeyClientId", + user_user1_token_apiKeyClientSecret: "apiKeyClientSecret", + + // User2 migrated data + user_user2_token_accessToken: null as any, + user_user2_token_refreshToken: null as any, + user_user2_token_apiKeyClientId: null as any, + user_user2_token_apiKeyClientSecret: null as any, + + // Global state provider data + // use pattern global_{stateDefinitionName}_{keyDefinitionKey} for global data + global_tokenDiskLocal_emailTwoFactorTokenRecord: { + user1Email: "twoFactorToken", + user2Email: "twoFactorToken", + }, + + global: { + // no longer has twoFactorToken + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2", "user3"], + user1: { + tokens: { + otherStuff: "overStuff2", + }, + profile: { + email: "user1Email", + otherStuff: "overStuff3", + }, + keys: { + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }, + user2: { + tokens: { + otherStuff: "overStuff2", + }, + profile: { + email: "user2Email", + otherStuff: "overStuff3", + }, + keys: { + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("TokenServiceStateProviderMigrator", () => { + let helper: MockProxy; + let sut: TokenServiceStateProviderMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(preMigrationJson(), 37); + sut = new TokenServiceStateProviderMigrator(37, 38); + }); + + it("should remove state service data from all accounts that have it", async () => { + await sut.migrate(helper); + + expect(helper.set).toHaveBeenCalledWith("user1", { + tokens: { + otherStuff: "overStuff2", + }, + profile: { + email: "user1Email", + otherStuff: "overStuff3", + }, + keys: { + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }); + + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + expect(helper.set).not.toHaveBeenCalledWith("user3", any()); + }); + + it("should migrate data to state providers for defined accounts that have the data", async () => { + await sut.migrate(helper); + + // Two factor Token Migration + expect(helper.setToGlobal).toHaveBeenLastCalledWith( + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + { + user1Email: "twoFactorToken", + user2Email: "twoFactorToken", + }, + ); + expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", ACCESS_TOKEN_DISK, "accessToken"); + expect(helper.setToUser).toHaveBeenCalledWith("user1", REFRESH_TOKEN_DISK, "refreshToken"); + expect(helper.setToUser).toHaveBeenCalledWith( + "user1", + API_KEY_CLIENT_ID_DISK, + "apiKeyClientId", + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user1", + API_KEY_CLIENT_SECRET_DISK, + "apiKeyClientSecret", + ); + + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", ACCESS_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", REFRESH_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_ID_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_SECRET_DISK, any()); + + // Expect that we didn't migrate anything to user 3 + + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", ACCESS_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", REFRESH_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_ID_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_SECRET_DISK, any()); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 38); + sut = new TokenServiceStateProviderMigrator(37, 38); + }); + + it("should null out newly migrated entries in state provider framework", async () => { + await sut.rollback(helper); + + expect(helper.setToGlobal).toHaveBeenCalledWith( + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + null, + ); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", ACCESS_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user1", REFRESH_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user1", API_KEY_CLIENT_ID_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user1", API_KEY_CLIENT_SECRET_DISK, null); + + expect(helper.setToUser).toHaveBeenCalledWith("user2", ACCESS_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user2", REFRESH_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user2", API_KEY_CLIENT_ID_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user2", API_KEY_CLIENT_SECRET_DISK, null); + + expect(helper.setToUser).toHaveBeenCalledWith("user3", ACCESS_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user3", REFRESH_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user3", API_KEY_CLIENT_ID_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user3", API_KEY_CLIENT_SECRET_DISK, null); + }); + + it("should add back data to all accounts that had migrated data (only user 1)", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("user1", { + tokens: { + accessToken: "accessToken", + refreshToken: "refreshToken", + otherStuff: "overStuff2", + }, + profile: { + apiKeyClientId: "apiKeyClientId", + email: "user1Email", + otherStuff: "overStuff3", + }, + keys: { + apiKeyClientSecret: "apiKeyClientSecret", + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }); + }); + + it("should add back the global twoFactorToken", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("global", { + twoFactorToken: "twoFactorToken", + otherStuff: "otherStuff1", + }); + }); + + it("should not add data back if data wasn't migrated or acct doesn't exist", async () => { + await sut.rollback(helper); + + // no data to add back for user2 (acct exists but no migrated data) and user3 (no acct) + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + expect(helper.set).not.toHaveBeenCalledWith("user3", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts new file mode 100644 index 00000000000..17753d21879 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts @@ -0,0 +1,231 @@ +import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper"; +import { Migrator } from "../migrator"; + +// Types to represent data as it is stored in JSON +type ExpectedAccountType = { + tokens?: { + accessToken?: string; + refreshToken?: string; + }; + profile?: { + apiKeyClientId?: string; + email?: string; + }; + keys?: { + apiKeyClientSecret?: string; + }; +}; + +type ExpectedGlobalType = { + twoFactorToken?: string; +}; + +export const EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL: KeyDefinitionLike = { + key: "emailTwoFactorTokenRecord", + stateDefinition: { + name: "tokenDiskLocal", + }, +}; + +const TOKEN_STATE_DEF_LIKE: StateDefinitionLike = { + name: "token", +}; + +export const ACCESS_TOKEN_DISK: KeyDefinitionLike = { + key: "accessToken", // matches KeyDefinition.key + stateDefinition: TOKEN_STATE_DEF_LIKE, +}; + +export const REFRESH_TOKEN_DISK: KeyDefinitionLike = { + key: "refreshToken", + stateDefinition: TOKEN_STATE_DEF_LIKE, +}; + +export const API_KEY_CLIENT_ID_DISK: KeyDefinitionLike = { + key: "apiKeyClientId", + stateDefinition: TOKEN_STATE_DEF_LIKE, +}; + +export const API_KEY_CLIENT_SECRET_DISK: KeyDefinitionLike = { + key: "apiKeyClientSecret", + stateDefinition: TOKEN_STATE_DEF_LIKE, +}; + +export class TokenServiceStateProviderMigrator extends Migrator<37, 38> { + async migrate(helper: MigrationHelper): Promise { + // Move global data + const globalData = await helper.get("global"); + + // Create new global record for 2FA token that we can accumulate data in + const emailTwoFactorTokenRecord = {}; + + const accounts = await helper.getAccounts(); + async function migrateAccount( + userId: string, + account: ExpectedAccountType | undefined, + globalTwoFactorToken: string | undefined, + emailTwoFactorTokenRecord: Record, + ): Promise { + let updatedAccount = false; + + // migrate 2FA token from global to user state + // Due to the existing implmentation, n users on the same device share the same global state value for 2FA token. + // So, we will just migrate it to all users to keep it valid for whichever was the user that set it previously. + // Note: don't bother migrating 2FA Token if user account or email is undefined + const email = account?.profile?.email; + if (globalTwoFactorToken != undefined && account != undefined && email != undefined) { + emailTwoFactorTokenRecord[email] = globalTwoFactorToken; + // Note: don't set updatedAccount to true here as we aren't updating + // the legacy user state, just migrating a global state to a new user state + } + + // Migrate access token + const existingAccessToken = account?.tokens?.accessToken; + + if (existingAccessToken != null) { + // Only migrate data that exists + await helper.setToUser(userId, ACCESS_TOKEN_DISK, existingAccessToken); + delete account.tokens.accessToken; + updatedAccount = true; + } + + // Migrate refresh token + const existingRefreshToken = account?.tokens?.refreshToken; + + if (existingRefreshToken != null) { + await helper.setToUser(userId, REFRESH_TOKEN_DISK, existingRefreshToken); + delete account.tokens.refreshToken; + updatedAccount = true; + } + + // Migrate API key client id + const existingApiKeyClientId = account?.profile?.apiKeyClientId; + + if (existingApiKeyClientId != null) { + await helper.setToUser(userId, API_KEY_CLIENT_ID_DISK, existingApiKeyClientId); + delete account.profile.apiKeyClientId; + updatedAccount = true; + } + + // Migrate API key client secret + const existingApiKeyClientSecret = account?.keys?.apiKeyClientSecret; + if (existingApiKeyClientSecret != null) { + await helper.setToUser(userId, API_KEY_CLIENT_SECRET_DISK, existingApiKeyClientSecret); + delete account.keys.apiKeyClientSecret; + updatedAccount = true; + } + + if (updatedAccount) { + // Save the migrated account only if it was updated + await helper.set(userId, account); + } + } + + await Promise.all([ + ...accounts.map(({ userId, account }) => + migrateAccount(userId, account, globalData?.twoFactorToken, emailTwoFactorTokenRecord), + ), + ]); + + // Save the global 2FA token record + await helper.setToGlobal(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, emailTwoFactorTokenRecord); + + // Delete global data + delete globalData?.twoFactorToken; + await helper.set("global", globalData); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + // Since we migrated the global 2FA token to all users, we need to rollback the 2FA token for all users + // but we only need to set it to the global state once + + // Go through accounts and find the first user that has a non-null email and 2FA token + let migratedTwoFactorToken: string | null = null; + for (const { account } of accounts) { + const email = account?.profile?.email; + if (email == null) { + continue; + } + const emailTwoFactorTokenRecord: Record = await helper.getFromGlobal( + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + ); + + migratedTwoFactorToken = emailTwoFactorTokenRecord[email]; + + if (migratedTwoFactorToken != null) { + break; + } + } + + if (migratedTwoFactorToken != null) { + let legacyGlobal = await helper.get("global"); + if (!legacyGlobal) { + legacyGlobal = {}; + } + legacyGlobal.twoFactorToken = migratedTwoFactorToken; + await helper.set("global", legacyGlobal); + } + + // delete global 2FA token record + await helper.setToGlobal(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, null); + + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + let updatedLegacyAccount = false; + + // Rollback access token + const migratedAccessToken = await helper.getFromUser(userId, ACCESS_TOKEN_DISK); + + if (account?.tokens && migratedAccessToken != null) { + account.tokens.accessToken = migratedAccessToken; + updatedLegacyAccount = true; + } + + await helper.setToUser(userId, ACCESS_TOKEN_DISK, null); + + // Rollback refresh token + const migratedRefreshToken = await helper.getFromUser(userId, REFRESH_TOKEN_DISK); + + if (account?.tokens && migratedRefreshToken != null) { + account.tokens.refreshToken = migratedRefreshToken; + updatedLegacyAccount = true; + } + + await helper.setToUser(userId, REFRESH_TOKEN_DISK, null); + + // Rollback API key client id + + const migratedApiKeyClientId = await helper.getFromUser( + userId, + API_KEY_CLIENT_ID_DISK, + ); + + if (account?.profile && migratedApiKeyClientId != null) { + account.profile.apiKeyClientId = migratedApiKeyClientId; + updatedLegacyAccount = true; + } + + await helper.setToUser(userId, API_KEY_CLIENT_ID_DISK, null); + + // Rollback API key client secret + const migratedApiKeyClientSecret = await helper.getFromUser( + userId, + API_KEY_CLIENT_SECRET_DISK, + ); + + if (account?.keys && migratedApiKeyClientSecret != null) { + account.keys.apiKeyClientSecret = migratedApiKeyClientSecret; + updatedLegacyAccount = true; + } + + await helper.setToUser(userId, API_KEY_CLIENT_SECRET_DISK, null); + + if (updatedLegacyAccount) { + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts b/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts index 4b002061e06..7d77bbbc868 100644 --- a/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts +++ b/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts @@ -2,7 +2,6 @@ import { Injectable, NgZone } from "@angular/core"; import { OidcClient } from "oidc-client-ts"; import { Subject, firstValueFrom } from "rxjs"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { ClientType } from "@bitwarden/common/enums"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -32,7 +31,6 @@ export class LastPassDirectImportService { ssoImportCallback$ = this._ssoImportCallback$.asObservable(); constructor( - private tokenService: TokenService, private cryptoFunctionService: CryptoFunctionService, private environmentService: EnvironmentService, private appIdService: AppIdService, @@ -44,7 +42,7 @@ export class LastPassDirectImportService { private dialogService: DialogService, private i18nService: I18nService, ) { - this.vault = new Vault(this.cryptoFunctionService, this.tokenService); + this.vault = new Vault(this.cryptoFunctionService); /** TODO: remove this in favor of dedicated service */ this.broadcasterService.subscribe("LastPassDirectImportService", (message: any) => { diff --git a/libs/importer/src/importers/lastpass/access/vault.ts b/libs/importer/src/importers/lastpass/access/vault.ts index 814390f5c80..13b8b62c108 100644 --- a/libs/importer/src/importers/lastpass/access/vault.ts +++ b/libs/importer/src/importers/lastpass/access/vault.ts @@ -1,6 +1,6 @@ import * as papa from "papaparse"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { decodeJwtTokenToJson } from "@bitwarden/auth/common"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -24,10 +24,7 @@ export class Vault { private client: Client; private cryptoUtils: CryptoUtils; - constructor( - private cryptoFunctionService: CryptoFunctionService, - private tokenService: TokenService, - ) { + constructor(private cryptoFunctionService: CryptoFunctionService) { this.cryptoUtils = new CryptoUtils(cryptoFunctionService); const parser = new Parser(cryptoFunctionService, this.cryptoUtils); this.client = new Client(parser, this.cryptoUtils); @@ -212,7 +209,7 @@ export class Vault { } private async getK1FromAccessToken(federatedUser: FederatedUserContext, b64: boolean) { - const decodedAccessToken = await this.tokenService.decodeToken(federatedUser.accessToken); + const decodedAccessToken = decodeJwtTokenToJson(federatedUser.accessToken); const k1 = decodedAccessToken?.LastPassK1 as string; if (k1 != null) { return b64 ? Utils.fromB64ToArray(k1) : Utils.fromByteStringToArray(k1); From 65534a132375e2ba9a4b2fc637328ea4073585c5 Mon Sep 17 00:00:00 2001 From: aj-bw <81774843+aj-bw@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:06:29 +0000 Subject: [PATCH 20/41] [AC-2304] added User Status check to revoke-restore as well as remove components (#8347) --- .../members/components/bulk/bulk-remove.component.ts | 5 ++++- .../members/components/bulk/bulk-restore-revoke.component.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts index 506c556f176..fdf499f0398 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts @@ -2,6 +2,7 @@ import { Component, Input } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { BulkUserDetails } from "./bulk-status.component"; @@ -14,7 +15,9 @@ export class BulkRemoveComponent { @Input() organizationId: string; @Input() set users(value: BulkUserDetails[]) { this._users = value; - this.showNoMasterPasswordWarning = this._users.some((u) => u.hasMasterPassword === false); + this.showNoMasterPasswordWarning = this._users.some( + (u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false, + ); } get users(): BulkUserDetails[] { diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts index 2b9ba67468c..a2ab93dd0e1 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts @@ -2,6 +2,7 @@ import { DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService } from "@bitwarden/components"; @@ -37,7 +38,9 @@ export class BulkRestoreRevokeComponent { this.isRevoking = data.isRevoking; this.organizationId = data.organizationId; this.users = data.users; - this.showNoMasterPasswordWarning = this.users.some((u) => u.hasMasterPassword === false); + this.showNoMasterPasswordWarning = this.users.some( + (u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false, + ); } get bulkTitle() { From b99153a01667a592f62ed009695b99bbc1c564e7 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Fri, 15 Mar 2024 15:53:05 -0400 Subject: [PATCH 21/41] [AC-2156] Billing State Provider Migration (#8133) * Added billing account profile state service * Update usages after removing state service functions * Added migrator * Updated bw.ts and main.background.ts * Removed comment * Updated state service dependencies to include billing service * Added missing mv3 factory and updated MainContextMenuHandler * updated autofill service and tests * Updated the remaining extensions usages * Updated desktop * Removed subjects where they weren't needed * Refactored billing service to have a single setter to avoid unecessary emissions * Refactored has premium guard to return an observable * Renamed services to match ADR https://github.com/bitwarden/contributing-docs/blob/f633f2cdd866c8f02801072e170ba9b77f76ec0d/docs/architecture/clients/presentation/angular.md#abstract--default-implementations * Updated property names to be a smidgen more descriptive and added jsdocs * Updated setting of canAccessPremium to automatically update when the underlying observable emits * Fixed build error after merge conflicts * Another build error from conflict * Removed autofill unit test changes from conflict * Updated login strategy to not set premium field using state service * Updated CLI to use billing state provider * Shortened names a bit * Fixed build --- .../login-strategy-service.factory.ts | 2 + .../autofill-service.factory.ts | 2 + .../browser/main-context-menu-handler.spec.ts | 12 +- .../browser/main-context-menu-handler.ts | 13 +- .../services/autofill.service.spec.ts | 16 +- .../src/autofill/services/autofill.service.ts | 6 +- .../browser/src/background/main.background.ts | 11 ++ ...g-account-profile-state-service.factory.ts | 28 +++ .../src/popup/settings/premium.component.html | 4 +- .../src/popup/settings/premium.component.ts | 3 + .../popup/send/send-add-edit.component.ts | 3 + .../components/action-buttons.component.ts | 24 ++- .../components/vault/attachments.component.ts | 3 + .../popup/components/vault/view.component.ts | 3 + apps/cli/src/bw.ts | 9 + apps/cli/src/commands/get.command.ts | 13 +- apps/cli/src/commands/serve.command.ts | 9 +- .../src/tools/send/commands/create.command.ts | 8 +- .../src/tools/send/commands/edit.command.ts | 11 +- apps/cli/src/tools/send/send.program.ts | 5 +- apps/cli/src/vault.program.ts | 5 +- apps/cli/src/vault/create.command.ts | 11 +- apps/cli/src/vault/delete.command.ts | 11 +- .../src/app/tools/send/add-edit.component.ts | 3 + .../vault/app/accounts/premium.component.html | 10 +- .../vault/app/accounts/premium.component.ts | 3 + .../vault/app/vault/attachments.component.ts | 3 + .../src/vault/app/vault/vault.component.ts | 13 +- .../src/vault/app/vault/view.component.ts | 3 + .../settings/two-factor-setup.component.ts | 12 +- .../emergency-access-attachments.component.ts | 3 + .../emergency-access.component.html | 2 +- .../emergency-access.component.ts | 18 +- .../emergency-add-edit-cipher.component.ts | 3 + .../settings/two-factor-setup.component.html | 2 +- .../settings/two-factor-setup.component.ts | 16 +- .../billing/individual/premium.component.html | 2 +- .../billing/individual/premium.component.ts | 12 +- .../individual/subscription.component.html | 4 +- .../individual/subscription.component.ts | 22 ++- .../individual/user-subscription.component.ts | 9 +- .../src/app/core/guards/has-premium.guard.ts | 37 ++-- .../src/app/layouts/user-layout.component.ts | 16 +- .../src/app/settings/settings.component.ts | 14 +- .../reports/pages/reports-home.component.ts | 10 +- .../src/app/tools/send/add-edit.component.ts | 3 + apps/web/src/app/tools/tools.component.ts | 21 ++- .../vault/components/premium-badge.stories.ts | 14 -- .../individual-vault/add-edit.component.ts | 7 +- .../individual-vault/attachments.component.ts | 3 + .../vault/individual-vault/vault.component.ts | 14 +- .../app/vault/org-vault/add-edit.component.ts | 3 + .../vault/org-vault/attachments.component.ts | 3 + .../src/directives/not-premium.directive.ts | 9 +- .../src/directives/premium.directive.ts | 30 ++-- .../src/services/jslib-services.module.ts | 9 + .../src/tools/send/add-edit.component.ts | 9 +- .../vault/components/attachments.component.ts | 7 +- .../src/vault/components/premium.component.ts | 20 +-- .../src/vault/components/view.component.ts | 14 +- .../auth-request-login.strategy.spec.ts | 4 + .../auth-request-login.strategy.ts | 3 + .../login-strategies/login.strategy.spec.ts | 6 +- .../common/login-strategies/login.strategy.ts | 5 +- .../password-login.strategy.spec.ts | 4 + .../password-login.strategy.ts | 3 + .../sso-login.strategy.spec.ts | 4 + .../login-strategies/sso-login.strategy.ts | 3 + .../user-api-login.strategy.spec.ts | 4 + .../user-api-login.strategy.ts | 3 + .../webauthn-login.strategy.spec.ts | 4 + .../webauthn-login.strategy.ts | 3 + .../login-strategy.service.spec.ts | 4 + .../login-strategy.service.ts | 7 + .../billing-account-profile-state.service.ts | 36 ++++ ...ling-account-profile-state.service.spec.ts | 165 ++++++++++++++++++ .../billing-account-profile-state.service.ts | 62 +++++++ .../platform/abstractions/state.service.ts | 5 - .../src/platform/models/domain/account.ts | 2 - .../src/platform/services/state.service.ts | 66 ------- .../src/platform/state/state-definitions.ts | 7 +- libs/common/src/state-migrations/migrate.ts | 6 +- ...account-profile-to-state-providers.spec.ts | 126 +++++++++++++ ...ling-account-profile-to-state-providers.ts | 67 +++++++ .../src/vault/services/sync/sync.service.ts | 9 +- 85 files changed, 942 insertions(+), 261 deletions(-) create mode 100644 apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts create mode 100644 libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts create mode 100644 libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts create mode 100644 libs/common/src/billing/services/account/billing-account-profile-state.service.ts create mode 100644 libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts diff --git a/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts b/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts index 078bfb8a637..b0ae87a75f7 100644 --- a/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts @@ -9,6 +9,7 @@ import { ApiServiceInitOptions, } from "../../../platform/background/service-factories/api-service.factory"; import { appIdServiceFactory } from "../../../platform/background/service-factories/app-id-service.factory"; +import { billingAccountProfileStateServiceFactory } from "../../../platform/background/service-factories/billing-account-profile-state-service.factory"; import { CryptoServiceInitOptions, cryptoServiceFactory, @@ -119,6 +120,7 @@ export function loginStrategyServiceFactory( await deviceTrustCryptoServiceFactory(cache, opts), await authRequestServiceFactory(cache, opts), await globalStateProviderFactory(cache, opts), + await billingAccountProfileStateServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts index bbbca2f16a4..d62e4857224 100644 --- a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts +++ b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts @@ -6,6 +6,7 @@ import { EventCollectionServiceInitOptions, eventCollectionServiceFactory, } from "../../../background/service-factories/event-collection-service.factory"; +import { billingAccountProfileStateServiceFactory } from "../../../platform/background/service-factories/billing-account-profile-state-service.factory"; import { CachedServices, factory, @@ -69,6 +70,7 @@ export function autofillServiceFactory( await logServiceFactory(cache, opts), await domainSettingsServiceFactory(cache, opts), await userVerificationServiceFactory(cache, opts), + await billingAccountProfileStateServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts index b827788d75c..67637da2fdd 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts @@ -3,6 +3,7 @@ import { of } from "rxjs"; import { NOOP_COMMAND_SUFFIX } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -18,6 +19,7 @@ describe("context-menu", () => { let autofillSettingsService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; + let billingAccountProfileStateService: MockProxy; let removeAllSpy: jest.SpyInstance void]>; let createSpy: jest.SpyInstance< @@ -32,6 +34,7 @@ describe("context-menu", () => { autofillSettingsService = mock(); i18nService = mock(); logService = mock(); + billingAccountProfileStateService = mock(); removeAllSpy = jest .spyOn(chrome.contextMenus, "removeAll") @@ -50,6 +53,7 @@ describe("context-menu", () => { autofillSettingsService, i18nService, logService, + billingAccountProfileStateService, ); autofillSettingsService.enableContextMenu$ = of(true); }); @@ -66,7 +70,7 @@ describe("context-menu", () => { }); it("has menu enabled, but does not have premium", async () => { - stateService.getCanAccessPremium.mockResolvedValue(false); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(false); const createdMenu = await sut.init(); expect(createdMenu).toBeTruthy(); @@ -74,7 +78,7 @@ describe("context-menu", () => { }); it("has menu enabled and has premium", async () => { - stateService.getCanAccessPremium.mockResolvedValue(true); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); const createdMenu = await sut.init(); expect(createdMenu).toBeTruthy(); @@ -128,7 +132,7 @@ describe("context-menu", () => { }); it("create entry for each cipher piece", async () => { - stateService.getCanAccessPremium.mockResolvedValue(true); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); await sut.loadOptions("TEST_TITLE", "1", createCipher()); @@ -137,7 +141,7 @@ describe("context-menu", () => { }); it("creates a login/unlock item for each context menu action option when user is not authenticated", async () => { - stateService.getCanAccessPremium.mockResolvedValue(true); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); await sut.loadOptions("TEST_TITLE", "NOOP"); diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index b7e26be4a9c..9422756e07b 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -17,6 +17,7 @@ import { SEPARATOR_ID, } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; @@ -27,6 +28,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { autofillSettingsServiceFactory } from "../../autofill/background/service_factories/autofill-settings-service.factory"; import { Account } from "../../models/account"; +import { billingAccountProfileStateServiceFactory } from "../../platform/background/service-factories/billing-account-profile-state-service.factory"; import { CachedServices } from "../../platform/background/service-factories/factory-options"; import { i18nServiceFactory, @@ -163,6 +165,7 @@ export class MainContextMenuHandler { private autofillSettingsService: AutofillSettingsServiceAbstraction, private i18nService: I18nService, private logService: LogService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} static async mv3Create(cachedServices: CachedServices) { @@ -196,6 +199,7 @@ export class MainContextMenuHandler { await autofillSettingsServiceFactory(cachedServices, serviceOptions), await i18nServiceFactory(cachedServices, serviceOptions), await logServiceFactory(cachedServices, serviceOptions), + await billingAccountProfileStateServiceFactory(cachedServices, serviceOptions), ); } @@ -217,7 +221,10 @@ export class MainContextMenuHandler { try { for (const options of this.initContextMenuItems) { - if (options.checkPremiumAccess && !(await this.stateService.getCanAccessPremium())) { + if ( + options.checkPremiumAccess && + !(await firstValueFrom(this.billingAccountProfileStateService.hasPremiumFromAnySource$)) + ) { continue; } @@ -312,7 +319,9 @@ export class MainContextMenuHandler { await createChildItem(COPY_USERNAME_ID); } - const canAccessPremium = await this.stateService.getCanAccessPremium(); + const canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); if (canAccessPremium && (!cipher || !Utils.isNullOrEmpty(cipher.login?.totp))) { await createChildItem(COPY_VERIFICATION_CODE_ID); } diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index eb70f0f7dc4..f6c1fa90677 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -8,6 +8,7 @@ import { DefaultDomainSettingsService, DomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -72,6 +73,7 @@ describe("AutofillService", () => { const eventCollectionService = mock(); const logService = mock(); const userVerificationService = mock(); + const billingAccountProfileStateService = mock(); beforeEach(() => { autofillService = new AutofillService( @@ -83,6 +85,7 @@ describe("AutofillService", () => { logService, domainSettingsService, userVerificationService, + billingAccountProfileStateService, ); domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); @@ -476,6 +479,7 @@ describe("AutofillService", () => { it("throws an error if an autofill did not occur for any of the passed pages", async () => { autofillOptions.tab.url = "https://a-different-url.com"; + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); try { await autofillService.doAutoFill(autofillOptions); @@ -487,7 +491,6 @@ describe("AutofillService", () => { }); it("will autofill login data for a page", async () => { - jest.spyOn(stateService, "getCanAccessPremium"); jest.spyOn(autofillService as any, "generateFillScript"); jest.spyOn(autofillService as any, "generateLoginFillScript"); jest.spyOn(logService, "info"); @@ -497,8 +500,6 @@ describe("AutofillService", () => { const autofillResult = await autofillService.doAutoFill(autofillOptions); const currentAutofillPageDetails = autofillOptions.pageDetails[0]; - expect(stateService.getCanAccessPremium).toHaveBeenCalled(); - expect(autofillService["getDefaultUriMatchStrategy"]).toHaveBeenCalled(); expect(autofillService["generateFillScript"]).toHaveBeenCalledWith( currentAutofillPageDetails.details, { @@ -660,7 +661,7 @@ describe("AutofillService", () => { it("returns a TOTP value", async () => { const totpCode = "123456"; autofillOptions.cipher.login.totp = "totp"; - jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValue(true); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(true); jest.spyOn(totpService, "getCode").mockResolvedValue(totpCode); @@ -673,7 +674,7 @@ describe("AutofillService", () => { it("does not return a TOTP value if the user does not have premium features", async () => { autofillOptions.cipher.login.totp = "totp"; - jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValue(false); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(false); jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(true); const autofillResult = await autofillService.doAutoFill(autofillOptions); @@ -707,7 +708,7 @@ describe("AutofillService", () => { it("returns a null value if the user cannot access premium and the organization does not use TOTP", async () => { autofillOptions.cipher.login.totp = "totp"; autofillOptions.cipher.organizationUseTotp = false; - jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValueOnce(false); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(false); const autofillResult = await autofillService.doAutoFill(autofillOptions); @@ -717,13 +718,12 @@ describe("AutofillService", () => { it("returns a null value if the user has disabled `auto TOTP copy`", async () => { autofillOptions.cipher.login.totp = "totp"; autofillOptions.cipher.organizationUseTotp = true; - jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValue(true); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(false); jest.spyOn(totpService, "getCode"); const autofillResult = await autofillService.doAutoFill(autofillOptions); - expect(stateService.getCanAccessPremium).toHaveBeenCalled(); expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled(); expect(totpService.getCode).not.toHaveBeenCalled(); expect(autofillResult).toBeNull(); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 3a809af0c38..e353a34ea07 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -5,6 +5,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { UriMatchStrategySetting, @@ -55,6 +56,7 @@ export default class AutofillService implements AutofillServiceInterface { private logService: LogService, private domainSettingsService: DomainSettingsService, private userVerificationService: UserVerificationService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} /** @@ -240,7 +242,9 @@ export default class AutofillService implements AutofillServiceInterface { let totp: string | null = null; - const canAccessPremium = await this.stateService.getCanAccessPremium(); + const canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); const defaultUriMatch = await this.getDefaultUriMatchStrategy(); if (!canAccessPremium) { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 23f415fc418..814b1cdb668 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -64,6 +64,8 @@ import { UserNotificationSettingsService, UserNotificationSettingsServiceAbstraction, } from "@bitwarden/common/autofill/services/user-notification-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -311,6 +313,7 @@ export default class MainBackground { biometricStateService: BiometricStateService; stateEventRunnerService: StateEventRunnerService; ssoLoginService: SsoLoginServiceAbstraction; + billingAccountProfileStateService: BillingAccountProfileStateService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -572,6 +575,10 @@ export default class MainBackground { this.stateService, ); + this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( + this.activeUserStateProvider, + ); + this.loginStrategyService = new LoginStrategyService( this.cryptoService, this.apiService, @@ -591,6 +598,7 @@ export default class MainBackground { this.deviceTrustCryptoService, this.authRequestService, this.globalStateProvider, + this.billingAccountProfileStateService, ); this.ssoLoginService = new SsoLoginService(this.stateProvider); @@ -718,6 +726,7 @@ export default class MainBackground { this.sendApiService, this.avatarService, logoutCallback, + this.billingAccountProfileStateService, ); this.eventUploadService = new EventUploadService( this.apiService, @@ -741,6 +750,7 @@ export default class MainBackground { this.logService, this.domainSettingsService, this.userVerificationService, + this.billingAccountProfileStateService, ); this.auditService = new AuditService(this.cryptoFunctionService, this.apiService); @@ -961,6 +971,7 @@ export default class MainBackground { this.autofillSettingsService, this.i18nService, this.logService, + this.billingAccountProfileStateService, ); this.cipherContextMenuHandler = new CipherContextMenuHandler( diff --git a/apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts b/apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts new file mode 100644 index 00000000000..80482eacb67 --- /dev/null +++ b/apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts @@ -0,0 +1,28 @@ +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; + +import { activeUserStateProviderFactory } from "./active-user-state-provider.factory"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; +import { StateProviderInitOptions } from "./state-provider.factory"; + +type BillingAccountProfileStateServiceFactoryOptions = FactoryOptions; + +export type BillingAccountProfileStateServiceInitOptions = + BillingAccountProfileStateServiceFactoryOptions & StateProviderInitOptions; + +export function billingAccountProfileStateServiceFactory( + cache: { + billingAccountProfileStateService?: BillingAccountProfileStateService; + } & CachedServices, + opts: BillingAccountProfileStateServiceInitOptions, +): Promise { + return factory( + cache, + "billingAccountProfileStateService", + opts, + async () => + new DefaultBillingAccountProfileStateService( + await activeUserStateProviderFactory(cache, opts), + ), + ); +} diff --git a/apps/browser/src/popup/settings/premium.component.html b/apps/browser/src/popup/settings/premium.component.html index 2727ee405b9..a8f9855e62d 100644 --- a/apps/browser/src/popup/settings/premium.component.html +++ b/apps/browser/src/popup/settings/premium.component.html @@ -12,7 +12,7 @@
- +

{{ "premiumNotCurrentMember" | i18n }}

{{ "premiumSignUpAndGet" | i18n }}

    @@ -61,7 +61,7 @@ > - +

    {{ "premiumCurrentMember" | i18n }}

    {{ "premiumCurrentMemberThanks" | i18n }}

    -
    +
    diff --git a/apps/web/src/app/billing/individual/premium.component.ts b/apps/web/src/app/billing/individual/premium.component.ts index 8f40c3f1c2c..e6e63264d5b 100644 --- a/apps/web/src/app/billing/individual/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium.component.ts @@ -1,14 +1,15 @@ import { Component, OnInit, ViewChild } from "@angular/core"; import { Router } from "@angular/router"; +import { firstValueFrom, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.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"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { PaymentComponent, TaxInfoComponent } from "../shared"; @@ -20,7 +21,7 @@ export class PremiumComponent implements OnInit { @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; - canAccessPremium = false; + canAccessPremium$: Observable; selfHosted = false; premiumPrice = 10; familyPlanMaxUserCount = 6; @@ -39,17 +40,16 @@ export class PremiumComponent implements OnInit { private messagingService: MessagingService, private syncService: SyncService, private logService: LogService, - private stateService: StateService, private environmentService: EnvironmentService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) { this.selfHosted = platformUtilsService.isSelfHost(); this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl(); + this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; } async ngOnInit() { - this.canAccessPremium = await this.stateService.getCanAccessPremium(); - const premiumPersonally = await this.stateService.getHasPremiumPersonally(); - if (premiumPersonally) { + if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(["/settings/subscription/user-subscription"]); diff --git a/apps/web/src/app/billing/individual/subscription.component.html b/apps/web/src/app/billing/individual/subscription.component.html index 59326cb70ae..934a24570f4 100644 --- a/apps/web/src/app/billing/individual/subscription.component.html +++ b/apps/web/src/app/billing/individual/subscription.component.html @@ -1,6 +1,8 @@ - {{ "subscription" | i18n }} + {{ + "subscription" | i18n + }} {{ "paymentMethod" | i18n }} {{ "billingHistory" | i18n }} diff --git a/apps/web/src/app/billing/individual/subscription.component.ts b/apps/web/src/app/billing/individual/subscription.component.ts index 143d531e1d6..c316bae4f13 100644 --- a/apps/web/src/app/billing/individual/subscription.component.ts +++ b/apps/web/src/app/billing/individual/subscription.component.ts @@ -1,26 +1,24 @@ -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; +import { Observable } from "rxjs"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @Component({ templateUrl: "subscription.component.html", }) -export class SubscriptionComponent { - hasPremium: boolean; +export class SubscriptionComponent implements OnInit { + hasPremium$: Observable; selfHosted: boolean; constructor( - private stateService: StateService, private platformUtilsService: PlatformUtilsService, - ) {} + billingAccountProfileStateService: BillingAccountProfileStateService, + ) { + this.hasPremium$ = billingAccountProfileStateService.hasPremiumPersonally$; + } - async ngOnInit() { - this.hasPremium = await this.stateService.getHasPremiumPersonally(); + ngOnInit() { this.selfHosted = this.platformUtilsService.isSelfHost(); } - - get subscriptionRoute(): string { - return this.hasPremium ? "user-subscription" : "premium"; - } } diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index fd4bb62a5ee..3ec0cd54d12 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { lastValueFrom, Observable } from "rxjs"; +import { firstValueFrom, lastValueFrom, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigServiceAbstraction as ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; @@ -11,7 +12,6 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil 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 { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { DialogService } from "@bitwarden/components"; import { @@ -37,7 +37,6 @@ export class UserSubscriptionComponent implements OnInit { presentUserWithOffboardingSurvey$: Observable; constructor( - private stateService: StateService, private apiService: ApiService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, @@ -47,6 +46,7 @@ export class UserSubscriptionComponent implements OnInit { private dialogService: DialogService, private environmentService: EnvironmentService, private configService: ConfigService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) { this.selfHosted = platformUtilsService.isSelfHost(); this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl(); @@ -65,8 +65,7 @@ export class UserSubscriptionComponent implements OnInit { return; } - // eslint-disable-next-line @typescript-eslint/no-misused-promises - if (this.stateService.getHasPremiumPersonally()) { + if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) { this.loading = true; this.sub = await this.apiService.getUserSubscription(); } else { diff --git a/apps/web/src/app/core/guards/has-premium.guard.ts b/apps/web/src/app/core/guards/has-premium.guard.ts index bb4d07f1d16..ab544dafb61 100644 --- a/apps/web/src/app/core/guards/has-premium.guard.ts +++ b/apps/web/src/app/core/guards/has-premium.guard.ts @@ -4,32 +4,39 @@ import { RouterStateSnapshot, Router, CanActivateFn, + UrlTree, } from "@angular/router"; +import { Observable } from "rxjs"; +import { tap } from "rxjs/operators"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; /** * CanActivate guard that checks if the user has premium and otherwise triggers the "premiumRequired" * message and blocks navigation. */ export function hasPremiumGuard(): CanActivateFn { - return async (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + return ( + _route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot, + ): Observable => { const router = inject(Router); - const stateService = inject(StateService); const messagingService = inject(MessagingService); + const billingAccountProfileStateService = inject(BillingAccountProfileStateService); - const userHasPremium = await stateService.getCanAccessPremium(); - - if (!userHasPremium) { - messagingService.send("premiumRequired"); - } - - // Prevent trapping the user on the login page, since that's an awful UX flow - if (!userHasPremium && router.url === "/login") { - return router.createUrlTree(["/"]); - } - - return userHasPremium; + return billingAccountProfileStateService.hasPremiumFromAnySource$.pipe( + tap((userHasPremium: boolean) => { + if (!userHasPremium) { + messagingService.send("premiumRequired"); + } + }), + // Prevent trapping the user on the login page, since that's an awful UX flow + tap((userHasPremium: boolean) => { + if (!userHasPremium && router.url === "/login") { + return router.createUrlTree(["/"]); + } + }), + ); }; } diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 1f116ee76e4..2e1813697ef 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -1,15 +1,16 @@ import { CommonModule } from "@angular/common"; import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { RouterModule } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigServiceAbstraction as ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components"; @@ -48,10 +49,10 @@ export class UserLayoutComponent implements OnInit, OnDestroy { private ngZone: NgZone, private platformUtilsService: PlatformUtilsService, private organizationService: OrganizationService, - private stateService: StateService, private apiService: ApiService, private syncService: SyncService, private configService: ConfigService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit() { @@ -79,16 +80,21 @@ export class UserLayoutComponent implements OnInit, OnDestroy { } async load() { - const premium = await this.stateService.getHasPremiumPersonally(); + const hasPremiumPersonally = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumPersonally$, + ); + const hasPremiumFromOrg = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$, + ); const selfHosted = this.platformUtilsService.isSelfHost(); this.hasFamilySponsorshipAvailable = await this.organizationService.canManageSponsorships(); - const hasPremiumFromOrg = await this.stateService.getHasPremiumFromOrganization(); let billing = null; if (!selfHosted) { // TODO: We should remove the need to call this! billing = await this.apiService.getUserBillingHistory(); } - this.hideSubscription = !premium && hasPremiumFromOrg && (selfHosted || billing?.hasNoHistory); + this.hideSubscription = + !hasPremiumPersonally && hasPremiumFromOrg && (selfHosted || billing?.hasNoHistory); } } diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 918973b86c5..b5b198d0ac4 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -1,12 +1,12 @@ import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "../core"; - const BroadcasterSubscriptionId = "SettingsComponent"; @Component({ @@ -24,8 +24,8 @@ export class SettingsComponent implements OnInit, OnDestroy { private ngZone: NgZone, private platformUtilsService: PlatformUtilsService, private organizationService: OrganizationService, - private stateService: StateService, private apiService: ApiService, + private billingAccountProfileStateServiceAbstraction: BillingAccountProfileStateService, ) {} async ngOnInit() { @@ -51,9 +51,13 @@ export class SettingsComponent implements OnInit, OnDestroy { } async load() { - this.premium = await this.stateService.getHasPremiumPersonally(); + this.premium = await firstValueFrom( + this.billingAccountProfileStateServiceAbstraction.hasPremiumPersonally$, + ); this.hasFamilySponsorshipAvailable = await this.organizationService.canManageSponsorships(); - const hasPremiumFromOrg = await this.stateService.getHasPremiumFromOrganization(); + const hasPremiumFromOrg = await firstValueFrom( + this.billingAccountProfileStateServiceAbstraction.hasPremiumFromAnyOrganization$, + ); let billing = null; if (!this.selfHosted) { billing = await this.apiService.getUserBillingHistory(); diff --git a/apps/web/src/app/tools/reports/pages/reports-home.component.ts b/apps/web/src/app/tools/reports/pages/reports-home.component.ts index 3d85db8cb26..541193fafab 100644 --- a/apps/web/src/app/tools/reports/pages/reports-home.component.ts +++ b/apps/web/src/app/tools/reports/pages/reports-home.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { reports, ReportType } from "../reports"; import { ReportEntry, ReportVariant } from "../shared"; @@ -12,11 +13,12 @@ import { ReportEntry, ReportVariant } from "../shared"; export class ReportsHomeComponent implements OnInit { reports: ReportEntry[]; - constructor(private stateService: StateService) {} + constructor(private billingAccountProfileStateService: BillingAccountProfileStateService) {} async ngOnInit(): Promise { - const userHasPremium = await this.stateService.getCanAccessPremium(); - + const userHasPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); const reportRequiresPremium = userHasPremium ? ReportVariant.Enabled : ReportVariant.RequiresPremium; diff --git a/apps/web/src/app/tools/send/add-edit.component.ts b/apps/web/src/app/tools/send/add-edit.component.ts index 5eb1d361981..ee4be414889 100644 --- a/apps/web/src/app/tools/send/add-edit.component.ts +++ b/apps/web/src/app/tools/send/add-edit.component.ts @@ -5,6 +5,7 @@ import { FormBuilder } from "@angular/forms"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.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"; @@ -36,6 +37,7 @@ export class AddEditComponent extends BaseAddEditComponent { sendApiService: SendApiService, dialogService: DialogService, formBuilder: FormBuilder, + billingAccountProfileStateService: BillingAccountProfileStateService, protected dialogRef: DialogRef, @Inject(DIALOG_DATA) params: { sendId: string }, ) { @@ -52,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent { sendApiService, dialogService, formBuilder, + billingAccountProfileStateService, ); this.sendId = params.sendId; diff --git a/apps/web/src/app/tools/tools.component.ts b/apps/web/src/app/tools/tools.component.ts index 7c6020f6d12..52ef698fd3c 100644 --- a/apps/web/src/app/tools/tools.component.ts +++ b/apps/web/src/app/tools/tools.component.ts @@ -1,22 +1,33 @@ -import { Component, OnInit } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @Component({ selector: "app-tools", templateUrl: "tools.component.html", }) -export class ToolsComponent implements OnInit { +export class ToolsComponent implements OnInit, OnDestroy { + private componentIsDestroyed$ = new Subject(); canAccessPremium = false; constructor( - private stateService: StateService, private messagingService: MessagingService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit() { - this.canAccessPremium = await this.stateService.getCanAccessPremium(); + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntil(this.componentIsDestroyed$)) + .subscribe((canAccessPremium: boolean) => { + this.canAccessPremium = canAccessPremium; + }); + } + + ngOnDestroy() { + this.componentIsDestroyed$.next(true); + this.componentIsDestroyed$.complete(); } premiumRequired() { diff --git a/apps/web/src/app/vault/components/premium-badge.stories.ts b/apps/web/src/app/vault/components/premium-badge.stories.ts index 4585f235ba5..ffe11d73868 100644 --- a/apps/web/src/app/vault/components/premium-badge.stories.ts +++ b/apps/web/src/app/vault/components/premium-badge.stories.ts @@ -3,8 +3,6 @@ import { Meta, moduleMetadata, Story } from "@storybook/angular"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { BadgeModule, I18nMockService } from "@bitwarden/components"; import { PremiumBadgeComponent } from "./premium-badge.component"; @@ -15,12 +13,6 @@ class MockMessagingService implements MessagingService { } } -class MockedStateService implements Partial { - async getCanAccessPremium(options?: StorageOptions) { - return false; - } -} - export default { title: "Web/Premium Badge", component: PremiumBadgeComponent, @@ -42,12 +34,6 @@ export default { return new MockMessagingService(); }, }, - { - provide: StateService, - useFactory: () => { - return new MockedStateService(); - }, - }, ], }), ], diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index 00464882aef..8332b7e95f1 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -1,11 +1,13 @@ import { DatePipe } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType, ProductType } from "@bitwarden/common/enums"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -64,6 +66,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On dialogService: DialogService, datePipe: DatePipe, configService: ConfigServiceAbstraction, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cipherService, @@ -98,7 +101,9 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On this.hasPasswordHistory = this.cipher.hasPasswordHistory; this.cleanUp(); - this.canAccessPremium = await this.stateService.getCanAccessPremium(); + this.canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); if (this.showTotp()) { await this.totpUpdateCode(); const interval = this.totpService.getTimeInterval(this.cipher.login.totp); diff --git a/apps/web/src/app/vault/individual-vault/attachments.component.ts b/apps/web/src/app/vault/individual-vault/attachments.component.ts index 0ce66d07fa0..ae4e8fafabe 100644 --- a/apps/web/src/app/vault/individual-vault/attachments.component.ts +++ b/apps/web/src/app/vault/individual-vault/attachments.component.ts @@ -2,6 +2,7 @@ import { Component } from "@angular/core"; import { AttachmentsComponent as BaseAttachmentsComponent } from "@bitwarden/angular/vault/components/attachments.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -30,6 +31,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { logService: LogService, fileDownloadService: FileDownloadService, dialogService: DialogService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cipherService, @@ -42,6 +44,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { stateService, fileDownloadService, dialogService, + billingAccountProfileStateService, ); } 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 a501c7a4946..2a29eabc4fa 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -37,6 +37,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -182,6 +183,7 @@ export class VaultComponent implements OnInit, OnDestroy { private configService: ConfigServiceAbstraction, private apiService: ApiService, private userVerificationService: UserVerificationService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit() { @@ -201,7 +203,9 @@ export class VaultComponent implements OnInit, OnDestroy { : false; await this.syncService.fullSync(false); - const canAccessPremium = await this.stateService.getCanAccessPremium(); + const canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); this.showPremiumCallout = !this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost(); @@ -242,9 +246,6 @@ export class VaultComponent implements OnInit, OnDestroy { }); const filter$ = this.routedVaultFilterService.filter$; - const canAccessPremium$ = Utils.asyncToObservable(() => - this.stateService.getCanAccessPremium(), - ).pipe(shareReplay({ refCount: true, bufferSize: 1 })); const allCollections$ = Utils.asyncToObservable(() => this.collectionService.getAllDecrypted()); const nestedCollections$ = allCollections$.pipe( map((collections) => getNestedCollectionTree(collections)), @@ -368,7 +369,7 @@ export class VaultComponent implements OnInit, OnDestroy { switchMap(() => combineLatest([ filter$, - canAccessPremium$, + this.billingAccountProfileStateService.hasPremiumFromAnySource$, allCollections$, this.organizationService.organizations$, ciphers$, @@ -513,8 +514,7 @@ export class VaultComponent implements OnInit, OnDestroy { return; } - const canAccessPremium = await this.stateService.getCanAccessPremium(); - if (cipher.organizationId == null && !canAccessPremium) { + if (cipher.organizationId == null && !this.canAccessPremium) { this.messagingService.send("premiumRequired"); return; } else if (cipher.organizationId != null) { diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index 567dcf05df5..cb879dfcc75 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -6,6 +6,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -54,6 +55,7 @@ export class AddEditComponent extends BaseAddEditComponent { dialogService: DialogService, datePipe: DatePipe, configService: ConfigServiceAbstraction, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cipherService, @@ -75,6 +77,7 @@ export class AddEditComponent extends BaseAddEditComponent { dialogService, datePipe, configService, + billingAccountProfileStateService, ); } diff --git a/apps/web/src/app/vault/org-vault/attachments.component.ts b/apps/web/src/app/vault/org-vault/attachments.component.ts index ca6e0faccd5..f7ef372a2e3 100644 --- a/apps/web/src/app/vault/org-vault/attachments.component.ts +++ b/apps/web/src/app/vault/org-vault/attachments.component.ts @@ -2,6 +2,7 @@ import { Component } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -34,6 +35,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { logService: LogService, fileDownloadService: FileDownloadService, dialogService: DialogService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cipherService, @@ -45,6 +47,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { logService, fileDownloadService, dialogService, + billingAccountProfileStateService, ); } diff --git a/libs/angular/src/directives/not-premium.directive.ts b/libs/angular/src/directives/not-premium.directive.ts index 46fbaa17619..3aee9b192d2 100644 --- a/libs/angular/src/directives/not-premium.directive.ts +++ b/libs/angular/src/directives/not-premium.directive.ts @@ -1,6 +1,7 @@ import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; +import { firstValueFrom } from "rxjs"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; /** * Hides the element if the user has premium. @@ -12,11 +13,13 @@ export class NotPremiumDirective implements OnInit { constructor( private templateRef: TemplateRef, private viewContainer: ViewContainerRef, - private stateService: StateService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit(): Promise { - const premium = await this.stateService.getCanAccessPremium(); + const premium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); if (premium) { this.viewContainer.clear(); diff --git a/libs/angular/src/directives/premium.directive.ts b/libs/angular/src/directives/premium.directive.ts index 9e2991e97c9..d475669a1ab 100644 --- a/libs/angular/src/directives/premium.directive.ts +++ b/libs/angular/src/directives/premium.directive.ts @@ -1,6 +1,7 @@ -import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; +import { Directive, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; /** * Only shows the element if the user has premium. @@ -8,20 +9,29 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv @Directive({ selector: "[appPremium]", }) -export class PremiumDirective implements OnInit { +export class PremiumDirective implements OnInit, OnDestroy { + private directiveIsDestroyed$ = new Subject(); + constructor( private templateRef: TemplateRef, private viewContainer: ViewContainerRef, - private stateService: StateService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit(): Promise { - const premium = await this.stateService.getCanAccessPremium(); + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntil(this.directiveIsDestroyed$)) + .subscribe((premium: boolean) => { + if (premium) { + this.viewContainer.clear(); + } else { + this.viewContainer.createEmbeddedView(this.templateRef); + } + }); + } - if (premium) { - this.viewContainer.createEmbeddedView(this.templateRef); - } else { - this.viewContainer.clear(); - } + ngOnDestroy() { + this.directiveIsDestroyed$.next(true); + this.directiveIsDestroyed$.complete(); } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 58d614bb9cc..b153c8219b3 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -96,9 +96,11 @@ import { DomainSettingsService, DefaultDomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.service"; import { PaymentMethodWarningsServiceAbstraction } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; +import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services/payment-method-warnings.service"; @@ -368,6 +370,7 @@ const typesafeProviders: Array = [ DeviceTrustCryptoServiceAbstraction, AuthRequestServiceAbstraction, GlobalStateProvider, + BillingAccountProfileStateService, ], }), safeProvider({ @@ -576,6 +579,7 @@ const typesafeProviders: Array = [ SendApiServiceAbstraction, AvatarServiceAbstraction, LOGOUT_CALLBACK, + BillingAccountProfileStateService, ], }), safeProvider({ provide: BroadcasterServiceAbstraction, useClass: BroadcasterService, deps: [] }), @@ -1045,6 +1049,11 @@ const typesafeProviders: Array = [ useClass: PaymentMethodWarningsService, deps: [BillingApiServiceAbstraction, StateProvider], }), + safeProvider({ + provide: BillingAccountProfileStateService, + useClass: DefaultBillingAccountProfileStateService, + deps: [ActiveUserStateProvider], + }), ]; function encryptServiceFactory( diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 9742de1a7b8..dafac1e92ba 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -5,6 +5,7 @@ import { BehaviorSubject, Subject, concatMap, firstValueFrom, map, takeUntil } f import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.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"; @@ -116,6 +117,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected sendApiService: SendApiService, protected dialogService: DialogService, protected formBuilder: FormBuilder, + protected billingAccountProfileStateService: BillingAccountProfileStateService, ) { this.typeOptions = [ { name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true }, @@ -188,6 +190,12 @@ export class AddEditComponent implements OnInit, OnDestroy { } }); + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntil(this.destroy$)) + .subscribe((hasPremiumFromAnySource) => { + this.canAccessPremium = hasPremiumFromAnySource; + }); + await this.load(); } @@ -205,7 +213,6 @@ export class AddEditComponent implements OnInit, OnDestroy { } async load() { - this.canAccessPremium = await this.stateService.getCanAccessPremium(); this.emailVerified = await this.stateService.getEmailVerified(); this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File; diff --git a/libs/angular/src/vault/components/attachments.component.ts b/libs/angular/src/vault/components/attachments.component.ts index 2c81dccdc77..fc86f2f5277 100644 --- a/libs/angular/src/vault/components/attachments.component.ts +++ b/libs/angular/src/vault/components/attachments.component.ts @@ -1,6 +1,8 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; @@ -42,6 +44,7 @@ export class AttachmentsComponent implements OnInit { protected stateService: StateService, protected fileDownloadService: FileDownloadService, protected dialogService: DialogService, + protected billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async ngOnInit() { @@ -185,7 +188,9 @@ export class AttachmentsComponent implements OnInit { await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain), ); - const canAccessPremium = await this.stateService.getCanAccessPremium(); + const canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + ); this.canAccessAttachments = canAccessPremium || this.cipher.organizationId != null; if (!this.canAccessAttachments) { diff --git a/libs/angular/src/vault/components/premium.component.ts b/libs/angular/src/vault/components/premium.component.ts index 526e453b2c1..974a2b6cdd1 100644 --- a/libs/angular/src/vault/components/premium.component.ts +++ b/libs/angular/src/vault/components/premium.component.ts @@ -1,6 +1,8 @@ -import { Directive, OnInit } from "@angular/core"; +import { Directive } from "@angular/core"; +import { Observable, Subject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.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"; @@ -9,11 +11,12 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { DialogService } from "@bitwarden/components"; @Directive() -export class PremiumComponent implements OnInit { - isPremium = false; +export class PremiumComponent { + isPremium$: Observable; price = 10; refreshPromise: Promise; cloudWebVaultUrl: string; + private directiveIsDestroyed$ = new Subject(); constructor( protected i18nService: I18nService, @@ -22,13 +25,11 @@ export class PremiumComponent implements OnInit { private logService: LogService, protected stateService: StateService, protected dialogService: DialogService, - private environmentService: EnvironmentService, + environmentService: EnvironmentService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { - this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl(); - } - - async ngOnInit() { - this.isPremium = await this.stateService.getCanAccessPremium(); + this.cloudWebVaultUrl = environmentService.getCloudWebVaultUrl(); + this.isPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; } async refresh() { @@ -36,7 +37,6 @@ export class PremiumComponent implements OnInit { this.refreshPromise = this.apiService.refreshIdentityToken(); await this.refreshPromise; this.platformUtilsService.showToast("success", null, this.i18nService.t("refreshComplete")); - this.isPremium = await this.stateService.getCanAccessPremium(); } catch (e) { this.logService.error(e); } diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 365041010a7..42349737f0d 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -9,12 +9,13 @@ import { OnInit, Output, } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -68,6 +69,7 @@ export class ViewComponent implements OnDestroy, OnInit { private totpInterval: any; private previousCipherId: string; private passwordReprompted = false; + private directiveIsDestroyed$ = new Subject(); get fido2CredentialCreationDateValue(): string { const dateCreated = this.i18nService.t("dateCreated"); @@ -99,6 +101,7 @@ export class ViewComponent implements OnDestroy, OnInit { protected fileDownloadService: FileDownloadService, protected dialogService: DialogService, protected datePipe: DatePipe, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} ngOnInit() { @@ -116,11 +119,19 @@ export class ViewComponent implements OnDestroy, OnInit { } }); }); + + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntil(this.directiveIsDestroyed$)) + .subscribe((canAccessPremium: boolean) => { + this.canAccessPremium = canAccessPremium; + }); } ngOnDestroy() { this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); this.cleanUp(); + this.directiveIsDestroyed$.next(true); + this.directiveIsDestroyed$.complete(); } async load() { @@ -130,7 +141,6 @@ export class ViewComponent implements OnDestroy, OnInit { this.cipher = await cipher.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(cipher), ); - this.canAccessPremium = await this.stateService.getCanAccessPremium(); this.showPremiumRequiredTotp = this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp; diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index 6a045a8f623..18ac9f0bf78 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -5,6 +5,7 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -37,6 +38,7 @@ describe("AuthRequestLoginStrategy", () => { let stateService: MockProxy; let twoFactorService: MockProxy; let deviceTrustCryptoService: MockProxy; + let billingAccountProfileStateService: MockProxy; let authRequestLoginStrategy: AuthRequestLoginStrategy; let credentials: AuthRequestLoginCredentials; @@ -64,6 +66,7 @@ describe("AuthRequestLoginStrategy", () => { stateService = mock(); twoFactorService = mock(); deviceTrustCryptoService = mock(); + billingAccountProfileStateService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -81,6 +84,7 @@ describe("AuthRequestLoginStrategy", () => { stateService, twoFactorService, deviceTrustCryptoService, + billingAccountProfileStateService, ); tokenResponse = identityTokenResponseFactory(); diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index 01a2c970776..09312226d8b 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -9,6 +9,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -54,6 +55,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy { stateService: StateService, twoFactorService: TwoFactorService, private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cryptoService, @@ -65,6 +67,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); this.cache = new BehaviorSubject(data); diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index a9938bd39c4..6f3d480f201 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -14,6 +14,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -109,6 +110,7 @@ describe("LoginStrategy", () => { let twoFactorService: MockProxy; let policyService: MockProxy; let passwordStrengthService: MockProxy; + let billingAccountProfileStateService: MockProxy; let passwordLoginStrategy: PasswordLoginStrategy; let credentials: PasswordLoginCredentials; @@ -127,6 +129,7 @@ describe("LoginStrategy", () => { policyService = mock(); passwordStrengthService = mock(); + billingAccountProfileStateService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeAccessToken.calledWith(accessToken).mockResolvedValue(decodedToken); @@ -146,6 +149,7 @@ describe("LoginStrategy", () => { passwordStrengthService, policyService, loginStrategyService, + billingAccountProfileStateService, ); credentials = new PasswordLoginCredentials(email, masterPassword); }); @@ -192,7 +196,6 @@ describe("LoginStrategy", () => { userId: userId, name: name, email: email, - hasPremiumPersonally: false, kdfIterations: kdfIterations, kdfType: kdf, }, @@ -409,6 +412,7 @@ describe("LoginStrategy", () => { passwordStrengthService, policyService, loginStrategyService, + billingAccountProfileStateService, ); apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index c6d441af236..f5f28dd0440 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -15,6 +15,7 @@ import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; @@ -68,6 +69,7 @@ export abstract class LoginStrategy { protected logService: LogService, protected stateService: StateService, protected twoFactorService: TwoFactorService, + protected billingAccountProfileStateService: BillingAccountProfileStateService, ) {} abstract exportCache(): CacheData; @@ -191,7 +193,6 @@ export abstract class LoginStrategy { userId, name: accountInformation.name, email: accountInformation.email, - hasPremiumPersonally: accountInformation.premium, kdfIterations: tokenResponse.kdfIterations, kdfMemory: tokenResponse.kdfMemory, kdfParallelism: tokenResponse.kdfParallelism, @@ -206,6 +207,8 @@ export abstract class LoginStrategy { adminAuthRequest: adminAuthRequest?.toJSON(), }), ); + + await this.billingAccountProfileStateService.setHasPremium(accountInformation.premium, false); } protected async processTokenResponse(response: IdentityTokenResponse): Promise { diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index 1ab908ac9e0..007c33afc6b 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -9,6 +9,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -61,6 +62,7 @@ describe("PasswordLoginStrategy", () => { let twoFactorService: MockProxy; let policyService: MockProxy; let passwordStrengthService: MockProxy; + let billingAccountProfileStateService: MockProxy; let passwordLoginStrategy: PasswordLoginStrategy; let credentials: PasswordLoginCredentials; @@ -79,6 +81,7 @@ describe("PasswordLoginStrategy", () => { twoFactorService = mock(); policyService = mock(); passwordStrengthService = mock(); + billingAccountProfileStateService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeAccessToken.mockResolvedValue({}); @@ -108,6 +111,7 @@ describe("PasswordLoginStrategy", () => { passwordStrengthService, policyService, loginStrategyService, + billingAccountProfileStateService, ); credentials = new PasswordLoginCredentials(email, masterPassword); tokenResponse = identityTokenResponseFactory(masterPasswordPolicy); diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index 2c99c243e07..2104595b450 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -13,6 +13,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -86,6 +87,7 @@ export class PasswordLoginStrategy extends LoginStrategy { private passwordStrengthService: PasswordStrengthServiceAbstraction, private policyService: PolicyService, private loginStrategyService: LoginStrategyServiceAbstraction, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cryptoService, @@ -97,6 +99,7 @@ export class PasswordLoginStrategy extends LoginStrategy { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); this.cache = new BehaviorSubject(data); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index 9946a6141f7..c987bcc95a6 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -9,6 +9,7 @@ import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/a import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -42,6 +43,7 @@ describe("SsoLoginStrategy", () => { let deviceTrustCryptoService: MockProxy; let authRequestService: MockProxy; let i18nService: MockProxy; + let billingAccountProfileStateService: MockProxy; let ssoLoginStrategy: SsoLoginStrategy; let credentials: SsoLoginCredentials; @@ -68,6 +70,7 @@ describe("SsoLoginStrategy", () => { deviceTrustCryptoService = mock(); authRequestService = mock(); i18nService = mock(); + billingAccountProfileStateService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -88,6 +91,7 @@ describe("SsoLoginStrategy", () => { deviceTrustCryptoService, authRequestService, i18nService, + billingAccountProfileStateService, ); credentials = new SsoLoginCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId); }); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 6b88a92f701..b8d1df6f577 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -10,6 +10,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -87,6 +88,7 @@ export class SsoLoginStrategy extends LoginStrategy { private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, private authRequestService: AuthRequestServiceAbstraction, private i18nService: I18nService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cryptoService, @@ -98,6 +100,7 @@ export class SsoLoginStrategy extends LoginStrategy { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); this.cache = new BehaviorSubject(data); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index da856a282eb..48f6fd32aba 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -4,6 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -36,6 +37,7 @@ describe("UserApiLoginStrategy", () => { let twoFactorService: MockProxy; let keyConnectorService: MockProxy; let environmentService: MockProxy; + let billingAccountProfileStateService: MockProxy; let apiLogInStrategy: UserApiLoginStrategy; let credentials: UserApiLoginCredentials; @@ -57,6 +59,7 @@ describe("UserApiLoginStrategy", () => { twoFactorService = mock(); keyConnectorService = mock(); environmentService = mock(); + billingAccountProfileStateService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.getTwoFactorToken.mockResolvedValue(null); @@ -75,6 +78,7 @@ describe("UserApiLoginStrategy", () => { twoFactorService, environmentService, keyConnectorService, + billingAccountProfileStateService, ); credentials = new UserApiLoginCredentials(apiClientId, apiClientSecret); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index 68916b6e8e1..9bb6d8fb125 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -7,6 +7,7 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service" import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -48,6 +49,7 @@ export class UserApiLoginStrategy extends LoginStrategy { twoFactorService: TwoFactorService, private environmentService: EnvironmentService, private keyConnectorService: KeyConnectorService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cryptoService, @@ -59,6 +61,7 @@ export class UserApiLoginStrategy extends LoginStrategy { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); this.cache = new BehaviorSubject(data); } diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index b7a56e62308..9ab64170c1d 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -7,6 +7,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -34,6 +35,7 @@ describe("WebAuthnLoginStrategy", () => { let logService!: MockProxy; let stateService!: MockProxy; let twoFactorService!: MockProxy; + let billingAccountProfileStateService: MockProxy; let webAuthnLoginStrategy!: WebAuthnLoginStrategy; @@ -68,6 +70,7 @@ describe("WebAuthnLoginStrategy", () => { logService = mock(); stateService = mock(); twoFactorService = mock(); + billingAccountProfileStateService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -84,6 +87,7 @@ describe("WebAuthnLoginStrategy", () => { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); // Create credentials diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index c42e6d65745..b60342f0b41 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -7,6 +7,7 @@ import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -48,6 +49,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy { logService: LogService, stateService: StateService, twoFactorService: TwoFactorService, + billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( cryptoService, @@ -59,6 +61,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy { logService, stateService, twoFactorService, + billingAccountProfileStateService, ); this.cache = new BehaviorSubject(data); diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts index 2304dc4d339..3d4c1b7b7d5 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts @@ -11,6 +11,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -50,6 +51,7 @@ describe("LoginStrategyService", () => { let policyService: MockProxy; let deviceTrustCryptoService: MockProxy; let authRequestService: MockProxy; + let billingAccountProfileStateService: MockProxy; let stateProvider: FakeGlobalStateProvider; let loginStrategyCacheExpirationState: FakeGlobalState; @@ -72,6 +74,7 @@ describe("LoginStrategyService", () => { policyService = mock(); deviceTrustCryptoService = mock(); authRequestService = mock(); + billingAccountProfileStateService = mock(); stateProvider = new FakeGlobalStateProvider(); sut = new LoginStrategyService( @@ -93,6 +96,7 @@ describe("LoginStrategyService", () => { deviceTrustCryptoService, authRequestService, stateProvider, + billingAccountProfileStateService, ); loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY); diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index 7ef8432aa5f..5c0e4140446 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -20,6 +20,7 @@ import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { PreloginRequest } from "@bitwarden/common/models/request/prelogin.request"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; @@ -101,6 +102,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, protected authRequestService: AuthRequestServiceAbstraction, protected stateProvider: GlobalStateProvider, + protected billingAccountProfileStateService: BillingAccountProfileStateService, ) { this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY); this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY); @@ -355,6 +357,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.passwordStrengthService, this.policyService, this, + this.billingAccountProfileStateService, ); case AuthenticationType.Sso: return new SsoLoginStrategy( @@ -372,6 +375,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.deviceTrustCryptoService, this.authRequestService, this.i18nService, + this.billingAccountProfileStateService, ); case AuthenticationType.UserApiKey: return new UserApiLoginStrategy( @@ -387,6 +391,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.twoFactorService, this.environmentService, this.keyConnectorService, + this.billingAccountProfileStateService, ); case AuthenticationType.AuthRequest: return new AuthRequestLoginStrategy( @@ -401,6 +406,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.stateService, this.twoFactorService, this.deviceTrustCryptoService, + this.billingAccountProfileStateService, ); case AuthenticationType.WebAuthn: return new WebAuthnLoginStrategy( @@ -414,6 +420,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.logService, this.stateService, this.twoFactorService, + this.billingAccountProfileStateService, ); } }), diff --git a/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts b/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts new file mode 100644 index 00000000000..e07dec3cf90 --- /dev/null +++ b/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts @@ -0,0 +1,36 @@ +import { Observable } from "rxjs"; + +export type BillingAccountProfile = { + hasPremiumPersonally: boolean; + hasPremiumFromAnyOrganization: boolean; +}; + +export abstract class BillingAccountProfileStateService { + /** + * Emits `true` when the active user's account has been granted premium from any of the + * organizations it is a member of. Otherwise, emits `false` + */ + hasPremiumFromAnyOrganization$: Observable; + + /** + * Emits `true` when the active user's account has an active premium subscription at the + * individual user level + */ + hasPremiumPersonally$: Observable; + + /** + * Emits `true` when either `hasPremiumPersonally` or `hasPremiumFromAnyOrganization` is `true` + */ + hasPremiumFromAnySource$: Observable; + + /** + * Sets the active user's premium status fields upon every full sync, either from their personal + * subscription to premium, or an organization they're a part of that grants them premium. + * @param hasPremiumPersonally + * @param hasPremiumFromAnyOrganization + */ + abstract setHasPremium( + hasPremiumPersonally: boolean, + hasPremiumFromAnyOrganization: boolean, + ): Promise; +} diff --git a/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts b/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts new file mode 100644 index 00000000000..4a2a94e9c60 --- /dev/null +++ b/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts @@ -0,0 +1,165 @@ +import { firstValueFrom } from "rxjs"; + +import { + FakeAccountService, + FakeActiveUserStateProvider, + mockAccountServiceWith, + FakeActiveUserState, + trackEmissions, +} from "../../../../spec"; +import { UserId } from "../../../types/guid"; +import { BillingAccountProfile } from "../../abstractions/account/billing-account-profile-state.service"; + +import { + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + DefaultBillingAccountProfileStateService, +} from "./billing-account-profile-state.service"; + +describe("BillingAccountProfileStateService", () => { + let activeUserStateProvider: FakeActiveUserStateProvider; + let sut: DefaultBillingAccountProfileStateService; + let billingAccountProfileState: FakeActiveUserState; + let accountService: FakeAccountService; + + const userId = "fakeUserId" as UserId; + + beforeEach(() => { + accountService = mockAccountServiceWith(userId); + activeUserStateProvider = new FakeActiveUserStateProvider(accountService); + + sut = new DefaultBillingAccountProfileStateService(activeUserStateProvider); + + billingAccountProfileState = activeUserStateProvider.getFake( + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + ); + }); + + afterEach(() => { + return jest.resetAllMocks(); + }); + + describe("accountHasPremiumFromAnyOrganization$", () => { + it("should emit changes in hasPremiumFromAnyOrganization", async () => { + billingAccountProfileState.nextState({ + hasPremiumPersonally: false, + hasPremiumFromAnyOrganization: true, + }); + + expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true); + }); + + it("should emit once when calling setHasPremium once", async () => { + const emissions = trackEmissions(sut.hasPremiumFromAnyOrganization$); + const startingEmissionCount = emissions.length; + + await sut.setHasPremium(true, true); + + const endingEmissionCount = emissions.length; + expect(endingEmissionCount - startingEmissionCount).toBe(1); + }); + }); + + describe("hasPremiumPersonally$", () => { + it("should emit changes in hasPremiumPersonally", async () => { + billingAccountProfileState.nextState({ + hasPremiumPersonally: true, + hasPremiumFromAnyOrganization: false, + }); + + expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true); + }); + + it("should emit once when calling setHasPremium once", async () => { + const emissions = trackEmissions(sut.hasPremiumPersonally$); + const startingEmissionCount = emissions.length; + + await sut.setHasPremium(true, true); + + const endingEmissionCount = emissions.length; + expect(endingEmissionCount - startingEmissionCount).toBe(1); + }); + }); + + describe("canAccessPremium$", () => { + it("should emit changes in hasPremiumPersonally", async () => { + billingAccountProfileState.nextState({ + hasPremiumPersonally: true, + hasPremiumFromAnyOrganization: false, + }); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("should emit changes in hasPremiumFromAnyOrganization", async () => { + billingAccountProfileState.nextState({ + hasPremiumPersonally: false, + hasPremiumFromAnyOrganization: true, + }); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("should emit changes in both hasPremiumPersonally and hasPremiumFromAnyOrganization", async () => { + billingAccountProfileState.nextState({ + hasPremiumPersonally: true, + hasPremiumFromAnyOrganization: true, + }); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("should emit once when calling setHasPremium once", async () => { + const emissions = trackEmissions(sut.hasPremiumFromAnySource$); + const startingEmissionCount = emissions.length; + + await sut.setHasPremium(true, true); + + const endingEmissionCount = emissions.length; + expect(endingEmissionCount - startingEmissionCount).toBe(1); + }); + }); + + describe("setHasPremium", () => { + it("should have `hasPremiumPersonally$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => { + await sut.setHasPremium(true, false); + + expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true); + }); + + it("should have `hasPremiumFromAnyOrganization$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => { + await sut.setHasPremium(false, true); + + expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true); + }); + + it("should have `hasPremiumPersonally$` emit `false` when passing `false` as an argument for hasPremiumPersonally", async () => { + await sut.setHasPremium(false, false); + + expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false); + }); + + it("should have `hasPremiumFromAnyOrganization$` emit `false` when passing `false` as an argument for hasPremiumFromAnyOrganization", async () => { + await sut.setHasPremium(false, false); + + expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false); + }); + + it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => { + await sut.setHasPremium(true, false); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => { + await sut.setHasPremium(false, true); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("should have `canAccessPremium$` emit `false` when passing `false` for all arguments", async () => { + await sut.setHasPremium(false, false); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(false); + }); + }); +}); diff --git a/libs/common/src/billing/services/account/billing-account-profile-state.service.ts b/libs/common/src/billing/services/account/billing-account-profile-state.service.ts new file mode 100644 index 00000000000..c6b6f104a8e --- /dev/null +++ b/libs/common/src/billing/services/account/billing-account-profile-state.service.ts @@ -0,0 +1,62 @@ +import { map, Observable } from "rxjs"; + +import { + ActiveUserState, + ActiveUserStateProvider, + BILLING_DISK, + KeyDefinition, +} from "../../../platform/state"; +import { + BillingAccountProfile, + BillingAccountProfileStateService, +} from "../../abstractions/account/billing-account-profile-state.service"; + +export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION = new KeyDefinition( + BILLING_DISK, + "accountProfile", + { + deserializer: (billingAccountProfile) => billingAccountProfile, + }, +); + +export class DefaultBillingAccountProfileStateService implements BillingAccountProfileStateService { + private billingAccountProfileState: ActiveUserState; + + hasPremiumFromAnyOrganization$: Observable; + hasPremiumPersonally$: Observable; + hasPremiumFromAnySource$: Observable; + + constructor(activeUserStateProvider: ActiveUserStateProvider) { + this.billingAccountProfileState = activeUserStateProvider.get( + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + ); + + this.hasPremiumFromAnyOrganization$ = this.billingAccountProfileState.state$.pipe( + map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumFromAnyOrganization), + ); + + this.hasPremiumPersonally$ = this.billingAccountProfileState.state$.pipe( + map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumPersonally), + ); + + this.hasPremiumFromAnySource$ = this.billingAccountProfileState.state$.pipe( + map( + (billingAccountProfile) => + billingAccountProfile?.hasPremiumFromAnyOrganization || + billingAccountProfile?.hasPremiumPersonally, + ), + ); + } + + async setHasPremium( + hasPremiumPersonally: boolean, + hasPremiumFromAnyOrganization: boolean, + ): Promise { + await this.billingAccountProfileState.update((billingAccountProfile) => { + return { + hasPremiumPersonally: hasPremiumPersonally, + hasPremiumFromAnyOrganization: hasPremiumFromAnyOrganization, + }; + }); + } +} diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 938720daaae..12bac7de151 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -61,11 +61,6 @@ export abstract class StateService { setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise; getBiometricFingerprintValidated: (options?: StorageOptions) => Promise; setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise; - getCanAccessPremium: (options?: StorageOptions) => Promise; - getHasPremiumPersonally: (options?: StorageOptions) => Promise; - setHasPremiumPersonally: (value: boolean, options?: StorageOptions) => Promise; - setHasPremiumFromOrganization: (value: boolean, options?: StorageOptions) => Promise; - getHasPremiumFromOrganization: (options?: StorageOptions) => Promise; getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise; setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise; /** diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index edb8f87d254..a647f64b5a5 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -172,8 +172,6 @@ export class AccountProfile { emailVerified?: boolean; everBeenUnlocked?: boolean; forceSetPasswordReason?: ForceSetPasswordReason; - hasPremiumPersonally?: boolean; - hasPremiumFromOrganization?: boolean; lastSync?: string; userId?: string; usesKeyConnector?: boolean; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 0ccd405dd13..b8acbf9e4f7 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -338,72 +338,6 @@ export class StateService< ); } - async getCanAccessPremium(options?: StorageOptions): Promise { - if (!(await this.getIsAuthenticated(options))) { - return false; - } - - return ( - (await this.getHasPremiumPersonally(options)) || - (await this.getHasPremiumFromOrganization(options)) - ); - } - - async getHasPremiumPersonally(options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - return account?.profile?.hasPremiumPersonally; - } - - async setHasPremiumPersonally(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.hasPremiumPersonally = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - - async getHasPremiumFromOrganization(options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - - if (account.profile?.hasPremiumFromOrganization) { - return true; - } - - // TODO: older server versions won't send the hasPremiumFromOrganization flag, so we're keeping the old logic - // for backwards compatibility. It can be removed after everyone has upgraded. - const organizations = await this.getOrganizations(options); - if (organizations == null) { - return false; - } - - for (const id of Object.keys(organizations)) { - const o = organizations[id]; - if (o.enabled && o.usersGetPremium && !o.isProviderUser) { - return true; - } - } - - return false; - } - - async setHasPremiumFromOrganization(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.hasPremiumFromOrganization = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getConvertAccountToKeyConnector(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 34b6bb097f0..6a41b82dcc8 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -23,6 +23,9 @@ export const ORGANIZATIONS_DISK = new StateDefinition("organizations", "disk"); export const POLICIES_DISK = new StateDefinition("policies", "disk"); export const PROVIDERS_DISK = new StateDefinition("providers", "disk"); +// Billing +export const BILLING_DISK = new StateDefinition("billing", "disk"); + // Auth export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); @@ -43,15 +46,11 @@ export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition( "disk", ); -// Billing - export const DOMAIN_SETTINGS_DISK = new StateDefinition("domainSettings", "disk"); - export const AUTOFILL_SETTINGS_DISK = new StateDefinition("autofillSettings", "disk"); export const AUTOFILL_SETTINGS_DISK_LOCAL = new StateDefinition("autofillSettingsLocal", "disk", { web: "disk-local", }); -export const BILLING_DISK = new StateDefinition("billing", "disk"); // Components diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 798af382209..dd35cc8cd05 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -33,6 +33,7 @@ import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to- import { VaultSettingsKeyMigrator } from "./migrations/36-move-show-card-and-identity-to-state-provider"; import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state-providers"; import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token-svc-to-state-provider"; +import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; @@ -42,7 +43,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 38; +export const CURRENT_VERSION = 39; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -82,7 +83,8 @@ export function createMigrationBuilder() { .with(MoveThemeToStateProviderMigrator, 34, 35) .with(VaultSettingsKeyMigrator, 35, 36) .with(AvatarColorMigrator, 36, 37) - .with(TokenServiceStateProviderMigrator, 37, CURRENT_VERSION); + .with(TokenServiceStateProviderMigrator, 37, 38) + .with(MoveBillingAccountProfileMigrator, 38, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts new file mode 100644 index 00000000000..8616dda81de --- /dev/null +++ b/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts @@ -0,0 +1,126 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + MoveBillingAccountProfileMigrator, +} from "./39-move-billing-account-profile-to-state-providers"; + +const exampleJSON = () => ({ + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2", "user-3"], + "user-1": { + profile: { + hasPremiumPersonally: true, + hasPremiumFromOrganization: false, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + otherStuff: "otherStuff4", + }, +}); + +const rollbackJSON = () => ({ + "user_user-1_billing_accountProfile": { + hasPremiumPersonally: true, + hasPremiumFromOrganization: false, + }, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2", "user-3"], + "user-1": { + profile: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + otherStuff: "otherStuff4", + }, +}); + +describe("MoveBillingAccountProfileToStateProviders migrator", () => { + let helper: MockProxy; + let sut: MoveBillingAccountProfileMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 39); + sut = new MoveBillingAccountProfileMigrator(38, 39); + }); + + it("removes from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("user-1", { + profile: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("sets hasPremiumPersonally value for account that have it", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + { hasPremiumFromOrganization: false, hasPremiumPersonally: true }, + ); + }); + + it("should not call extra setToUser", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledTimes(1); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 39); + sut = new MoveBillingAccountProfileMigrator(38, 39); + }); + + it("nulls out new values", async () => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledWith( + "user-1", + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + null, + ); + }); + + it("adds explicit value back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("user-1", { + profile: { + hasPremiumPersonally: true, + hasPremiumFromOrganization: false, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it.each(["user-2", "user-3"])( + "does not restore values when accounts are not present", + async (userId) => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith(userId, any()); + }, + ); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts b/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts new file mode 100644 index 00000000000..b6c0e531ef5 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts @@ -0,0 +1,67 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountType = { + profile?: { + hasPremiumPersonally?: boolean; + hasPremiumFromOrganization?: boolean; + }; +}; + +type ExpectedBillingAccountProfileType = { + hasPremiumPersonally: boolean; + hasPremiumFromOrganization: boolean; +}; + +export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION: KeyDefinitionLike = { + key: "accountProfile", + stateDefinition: { + name: "billing", + }, +}; + +export class MoveBillingAccountProfileMigrator extends Migrator<38, 39> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + const migrateAccount = async (userId: string, account: ExpectedAccountType): Promise => { + const hasPremiumPersonally = account?.profile?.hasPremiumPersonally; + const hasPremiumFromOrganization = account?.profile?.hasPremiumFromOrganization; + + if (hasPremiumPersonally != null || hasPremiumFromOrganization != null) { + await helper.setToUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, { + hasPremiumPersonally: hasPremiumPersonally, + hasPremiumFromOrganization: hasPremiumFromOrganization, + }); + + delete account?.profile?.hasPremiumPersonally; + delete account?.profile?.hasPremiumFromOrganization; + await helper.set(userId, account); + } + }; + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + const rollbackAccount = async (userId: string, account: ExpectedAccountType): Promise => { + const value = await helper.getFromUser( + userId, + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + ); + + if (account && value) { + account.profile = Object.assign(account.profile ?? {}, { + hasPremiumPersonally: value?.hasPremiumPersonally, + hasPremiumFromOrganization: value?.hasPremiumFromOrganization, + }); + await helper.set(userId, account); + } + + await helper.setToUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, null); + }; + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index 1b46bf43294..1b3e63d0012 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -11,6 +11,7 @@ import { AvatarService } from "../../../auth/abstractions/avatar.service"; import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service"; import { DomainsResponse } from "../../../models/response/domains.response"; import { SyncCipherNotification, @@ -62,6 +63,7 @@ export class SyncService implements SyncServiceAbstraction { private sendApiService: SendApiService, private avatarService: AvatarService, private logoutCallback: (expired: boolean) => Promise, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async getLastSync(): Promise { @@ -314,8 +316,11 @@ export class SyncService implements SyncServiceAbstraction { await this.avatarService.setAvatarColor(response.avatarColor); await this.stateService.setSecurityStamp(response.securityStamp); await this.stateService.setEmailVerified(response.emailVerified); - await this.stateService.setHasPremiumPersonally(response.premiumPersonally); - await this.stateService.setHasPremiumFromOrganization(response.premiumFromOrganization); + + await this.billingAccountProfileStateService.setHasPremium( + response.premiumPersonally, + response.premiumFromOrganization, + ); await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector); await this.setForceSetPasswordReasonIfNeeded(response); From 4fabc94c3c761993695de7095b1a3455277e0b43 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 15 Mar 2024 15:15:53 -0500 Subject: [PATCH 22/41] Add the key definition name in the error for buildKey (#8355) --- libs/common/src/platform/state/user-key-definition.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/common/src/platform/state/user-key-definition.ts b/libs/common/src/platform/state/user-key-definition.ts index 99e3039e1eb..3405b388375 100644 --- a/libs/common/src/platform/state/user-key-definition.ts +++ b/libs/common/src/platform/state/user-key-definition.ts @@ -138,7 +138,9 @@ export class UserKeyDefinition { buildKey(userId: UserId) { if (!Utils.isGuid(userId)) { - throw new Error("You cannot build a user key without a valid UserId"); + throw new Error( + `You cannot build a user key without a valid UserId, building for key ${this.fullName}`, + ); } return `user_${userId}_${this.stateDefinition.name}_${this.key}` as StorageKey; } From ca8628880bf674fc24a6263c7f557cfceb1a7e2b Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:05:23 -0400 Subject: [PATCH 23/41] PM-5263 - TokenService needs to actually use secure storage (#8356) --- apps/desktop/src/main.ts | 6 +++- .../illegal-secure-storage-service.ts | 28 +++++++++++++++++++ .../src/services/jslib-services.module.ts | 7 +---- 3 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/platform/services/illegal-secure-storage-service.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0b92fab894e..c9af632126f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -42,6 +42,7 @@ import { ELECTRON_SUPPORTS_SECURE_STORAGE } from "./platform/services/electron-p import { ElectronStateService } from "./platform/services/electron-state.service"; import { ElectronStorageService } from "./platform/services/electron-storage.service"; import { I18nMainService } from "./platform/services/i18n.main.service"; +import { IllegalSecureStorageService } from "./platform/services/illegal-secure-storage-service"; import { ElectronMainMessagingService } from "./services/electron-main-messaging.service"; export class Main { @@ -147,11 +148,14 @@ export class Main { this.environmentService = new EnvironmentService(stateProvider, accountService); + // Note: secure storage service is not available and should not be called in the main background process. + const illegalSecureStorageService = new IllegalSecureStorageService(); + this.tokenService = new TokenService( singleUserStateProvider, globalStateProvider, ELECTRON_SUPPORTS_SECURE_STORAGE, - this.storageService, + illegalSecureStorageService, ); this.migrationRunner = new MigrationRunner( diff --git a/apps/desktop/src/platform/services/illegal-secure-storage-service.ts b/apps/desktop/src/platform/services/illegal-secure-storage-service.ts new file mode 100644 index 00000000000..12f86226bef --- /dev/null +++ b/apps/desktop/src/platform/services/illegal-secure-storage-service.ts @@ -0,0 +1,28 @@ +import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; + +export class IllegalSecureStorageService implements AbstractStorageService { + constructor() {} + + get valuesRequireDeserialization(): boolean { + throw new Error("Method not implemented."); + } + has(key: string, options?: StorageOptions): Promise { + throw new Error("Method not implemented."); + } + save(key: string, obj: T, options?: StorageOptions): Promise { + throw new Error("Method not implemented."); + } + async get(key: string): Promise { + throw new Error("Method not implemented."); + } + async set(key: string, obj: T): Promise { + throw new Error("Method not implemented."); + } + async remove(key: string): Promise { + throw new Error("Method not implemented."); + } + async clear(): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index b153c8219b3..2ff90b79a45 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -485,12 +485,7 @@ const typesafeProviders: Array = [ safeProvider({ provide: TokenServiceAbstraction, useClass: TokenService, - deps: [ - SingleUserStateProvider, - GlobalStateProvider, - SUPPORTS_SECURE_STORAGE, - AbstractStorageService, - ], + deps: [SingleUserStateProvider, GlobalStateProvider, SUPPORTS_SECURE_STORAGE, SECURE_STORAGE], }), safeProvider({ provide: KeyGenerationServiceAbstraction, From bdbae39c3a363a10526d0ecc3859cc4e5d3fe326 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:13:47 -0400 Subject: [PATCH 24/41] [deps] Autofill: Update prettier-plugin-tailwindcss to v0.5.12 (#8367) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++++---- package.json | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c50ef6e69e..53f31340b33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -161,7 +161,7 @@ "postcss": "8.4.35", "postcss-loader": "7.3.4", "prettier": "3.2.2", - "prettier-plugin-tailwindcss": "0.5.11", + "prettier-plugin-tailwindcss": "0.5.12", "process": "0.11.10", "react": "18.2.0", "react-dom": "18.2.0", @@ -31209,9 +31209,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.5.11", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.11.tgz", - "integrity": "sha512-AvI/DNyMctyyxGOjyePgi/gqj5hJYClZ1avtQvLlqMT3uDZkRbi4HhGUpok3DRzv9z7Lti85Kdj3s3/1CeNI0w==", + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.12.tgz", + "integrity": "sha512-o74kiDBVE73oHW+pdkFSluHBL3cYEvru5YgEqNkBMFF7Cjv+w1vI565lTlfoJT4VLWDe0FMtZ7FkE/7a4pMXSQ==", "dev": true, "engines": { "node": ">=14.21.3" @@ -31229,6 +31229,7 @@ "prettier-plugin-marko": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, @@ -31266,6 +31267,9 @@ "prettier-plugin-organize-imports": { "optional": true }, + "prettier-plugin-sort-imports": { + "optional": true + }, "prettier-plugin-style-order": { "optional": true }, diff --git a/package.json b/package.json index fd89d4f1186..ec0f1d64747 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "postcss": "8.4.35", "postcss-loader": "7.3.4", "prettier": "3.2.2", - "prettier-plugin-tailwindcss": "0.5.11", + "prettier-plugin-tailwindcss": "0.5.12", "process": "0.11.10", "react": "18.2.0", "react-dom": "18.2.0", From 8fa1ef54bc7adced80522254cdf04be73d565b88 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 18 Mar 2024 12:06:43 -0400 Subject: [PATCH 25/41] Revert "PM-5263 - TokenService needs to actually use secure storage (#8356)" (#8370) This reverts commit ca8628880bf674fc24a6263c7f557cfceb1a7e2b. --- apps/desktop/src/main.ts | 6 +--- .../illegal-secure-storage-service.ts | 28 ------------------- .../src/services/jslib-services.module.ts | 7 ++++- 3 files changed, 7 insertions(+), 34 deletions(-) delete mode 100644 apps/desktop/src/platform/services/illegal-secure-storage-service.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c9af632126f..0b92fab894e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -42,7 +42,6 @@ import { ELECTRON_SUPPORTS_SECURE_STORAGE } from "./platform/services/electron-p import { ElectronStateService } from "./platform/services/electron-state.service"; import { ElectronStorageService } from "./platform/services/electron-storage.service"; import { I18nMainService } from "./platform/services/i18n.main.service"; -import { IllegalSecureStorageService } from "./platform/services/illegal-secure-storage-service"; import { ElectronMainMessagingService } from "./services/electron-main-messaging.service"; export class Main { @@ -148,14 +147,11 @@ export class Main { this.environmentService = new EnvironmentService(stateProvider, accountService); - // Note: secure storage service is not available and should not be called in the main background process. - const illegalSecureStorageService = new IllegalSecureStorageService(); - this.tokenService = new TokenService( singleUserStateProvider, globalStateProvider, ELECTRON_SUPPORTS_SECURE_STORAGE, - illegalSecureStorageService, + this.storageService, ); this.migrationRunner = new MigrationRunner( diff --git a/apps/desktop/src/platform/services/illegal-secure-storage-service.ts b/apps/desktop/src/platform/services/illegal-secure-storage-service.ts deleted file mode 100644 index 12f86226bef..00000000000 --- a/apps/desktop/src/platform/services/illegal-secure-storage-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; -import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; - -export class IllegalSecureStorageService implements AbstractStorageService { - constructor() {} - - get valuesRequireDeserialization(): boolean { - throw new Error("Method not implemented."); - } - has(key: string, options?: StorageOptions): Promise { - throw new Error("Method not implemented."); - } - save(key: string, obj: T, options?: StorageOptions): Promise { - throw new Error("Method not implemented."); - } - async get(key: string): Promise { - throw new Error("Method not implemented."); - } - async set(key: string, obj: T): Promise { - throw new Error("Method not implemented."); - } - async remove(key: string): Promise { - throw new Error("Method not implemented."); - } - async clear(): Promise { - throw new Error("Method not implemented."); - } -} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 2ff90b79a45..b153c8219b3 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -485,7 +485,12 @@ const typesafeProviders: Array = [ safeProvider({ provide: TokenServiceAbstraction, useClass: TokenService, - deps: [SingleUserStateProvider, GlobalStateProvider, SUPPORTS_SECURE_STORAGE, SECURE_STORAGE], + deps: [ + SingleUserStateProvider, + GlobalStateProvider, + SUPPORTS_SECURE_STORAGE, + AbstractStorageService, + ], }), safeProvider({ provide: KeyGenerationServiceAbstraction, From 123bee868c04bebb1be5b96381b312c89729669a Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:14:10 +0100 Subject: [PATCH 26/41] Update locales used on browser, desktop and web (#8371) Co-authored-by: Daniel James Smith --- apps/browser/src/platform/popup/locales.ts | 14 ++++++++++++++ apps/desktop/src/platform/app/locales.ts | 16 ++++++++++++++++ apps/web/src/app/shared/locales.ts | 18 ++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/apps/browser/src/platform/popup/locales.ts b/apps/browser/src/platform/popup/locales.ts index f24f7368db3..5fea00b97b7 100644 --- a/apps/browser/src/platform/popup/locales.ts +++ b/apps/browser/src/platform/popup/locales.ts @@ -7,6 +7,7 @@ import localeBn from "@angular/common/locales/bn"; import localeBs from "@angular/common/locales/bs"; import localeCa from "@angular/common/locales/ca"; import localeCs from "@angular/common/locales/cs"; +import localeCy from "@angular/common/locales/cy"; import localeDa from "@angular/common/locales/da"; import localeDe from "@angular/common/locales/de"; import localeEl from "@angular/common/locales/el"; @@ -19,6 +20,7 @@ import localeFa from "@angular/common/locales/fa"; import localeFi from "@angular/common/locales/fi"; import localeFil from "@angular/common/locales/fil"; import localeFr from "@angular/common/locales/fr"; +import localeGl from "@angular/common/locales/gl"; import localeHe from "@angular/common/locales/he"; import localeHi from "@angular/common/locales/hi"; import localeHr from "@angular/common/locales/hr"; @@ -33,9 +35,13 @@ import localeKo from "@angular/common/locales/ko"; import localeLt from "@angular/common/locales/lt"; import localeLv from "@angular/common/locales/lv"; import localeMl from "@angular/common/locales/ml"; +import localeMr from "@angular/common/locales/mr"; +import localeMy from "@angular/common/locales/my"; import localeNb from "@angular/common/locales/nb"; +import localeNe from "@angular/common/locales/ne"; import localeNl from "@angular/common/locales/nl"; import localeNn from "@angular/common/locales/nn"; +import localeOr from "@angular/common/locales/or"; import localePl from "@angular/common/locales/pl"; import localePtBr from "@angular/common/locales/pt"; import localePtPt from "@angular/common/locales/pt-PT"; @@ -46,6 +52,7 @@ import localeSk from "@angular/common/locales/sk"; import localeSl from "@angular/common/locales/sl"; import localeSr from "@angular/common/locales/sr"; import localeSv from "@angular/common/locales/sv"; +import localeTe from "@angular/common/locales/te"; import localeTh from "@angular/common/locales/th"; import localeTr from "@angular/common/locales/tr"; import localeUk from "@angular/common/locales/uk"; @@ -61,6 +68,7 @@ registerLocaleData(localeBn, "bn"); registerLocaleData(localeBs, "bs"); registerLocaleData(localeCa, "ca"); registerLocaleData(localeCs, "cs"); +registerLocaleData(localeCy, "cy"); registerLocaleData(localeDa, "da"); registerLocaleData(localeDe, "de"); registerLocaleData(localeEl, "el"); @@ -73,6 +81,7 @@ registerLocaleData(localeFa, "fa"); registerLocaleData(localeFi, "fi"); registerLocaleData(localeFil, "fil"); registerLocaleData(localeFr, "fr"); +registerLocaleData(localeGl, "gl"); registerLocaleData(localeHe, "he"); registerLocaleData(localeHi, "hi"); registerLocaleData(localeHr, "hr"); @@ -87,9 +96,13 @@ registerLocaleData(localeKo, "ko"); registerLocaleData(localeLt, "lt"); registerLocaleData(localeLv, "lv"); registerLocaleData(localeMl, "ml"); +registerLocaleData(localeMr, "mr"); +registerLocaleData(localeMy, "my"); registerLocaleData(localeNb, "nb"); +registerLocaleData(localeNe, "ne"); registerLocaleData(localeNl, "nl"); registerLocaleData(localeNn, "nn"); +registerLocaleData(localeOr, "or"); registerLocaleData(localePl, "pl"); registerLocaleData(localePtBr, "pt-BR"); registerLocaleData(localePtPt, "pt-PT"); @@ -100,6 +113,7 @@ registerLocaleData(localeSk, "sk"); registerLocaleData(localeSl, "sl"); registerLocaleData(localeSr, "sr"); registerLocaleData(localeSv, "sv"); +registerLocaleData(localeTe, "te"); registerLocaleData(localeTh, "th"); registerLocaleData(localeTr, "tr"); registerLocaleData(localeUk, "uk"); diff --git a/apps/desktop/src/platform/app/locales.ts b/apps/desktop/src/platform/app/locales.ts index c8ecfad0b04..351f6fb3869 100644 --- a/apps/desktop/src/platform/app/locales.ts +++ b/apps/desktop/src/platform/app/locales.ts @@ -8,6 +8,7 @@ import localeBn from "@angular/common/locales/bn"; import localeBs from "@angular/common/locales/bs"; import localeCa from "@angular/common/locales/ca"; import localeCs from "@angular/common/locales/cs"; +import localeCy from "@angular/common/locales/cy"; import localeDa from "@angular/common/locales/da"; import localeDe from "@angular/common/locales/de"; import localeEl from "@angular/common/locales/el"; @@ -21,6 +22,7 @@ import localeFa from "@angular/common/locales/fa"; import localeFi from "@angular/common/locales/fi"; import localeFil from "@angular/common/locales/fil"; import localeFr from "@angular/common/locales/fr"; +import localeGl from "@angular/common/locales/gl"; import localeHe from "@angular/common/locales/he"; import localeHi from "@angular/common/locales/hi"; import localeHr from "@angular/common/locales/hr"; @@ -32,11 +34,16 @@ import localeKa from "@angular/common/locales/ka"; import localeKm from "@angular/common/locales/km"; import localeKn from "@angular/common/locales/kn"; import localeKo from "@angular/common/locales/ko"; +import localeLt from "@angular/common/locales/lt"; import localeLv from "@angular/common/locales/lv"; import localeMl from "@angular/common/locales/ml"; +import localeMr from "@angular/common/locales/mr"; +import localeMy from "@angular/common/locales/my"; import localeNb from "@angular/common/locales/nb"; +import localeNe from "@angular/common/locales/ne"; import localeNl from "@angular/common/locales/nl"; import localeNn from "@angular/common/locales/nn"; +import localeOr from "@angular/common/locales/or"; import localePl from "@angular/common/locales/pl"; import localePtBr from "@angular/common/locales/pt"; import localePtPt from "@angular/common/locales/pt-PT"; @@ -48,6 +55,7 @@ import localeSl from "@angular/common/locales/sl"; import localeSr from "@angular/common/locales/sr"; import localeMe from "@angular/common/locales/sr-Latn-ME"; import localeSv from "@angular/common/locales/sv"; +import localeTe from "@angular/common/locales/te"; import localeTh from "@angular/common/locales/th"; import localeTr from "@angular/common/locales/tr"; import localeUk from "@angular/common/locales/uk"; @@ -64,6 +72,7 @@ registerLocaleData(localeBn, "bn"); registerLocaleData(localeBs, "bs"); registerLocaleData(localeCa, "ca"); registerLocaleData(localeCs, "cs"); +registerLocaleData(localeCy, "cy"); registerLocaleData(localeDa, "da"); registerLocaleData(localeDe, "de"); registerLocaleData(localeEl, "el"); @@ -77,6 +86,7 @@ registerLocaleData(localeFa, "fa"); registerLocaleData(localeFi, "fi"); registerLocaleData(localeFil, "fil"); registerLocaleData(localeFr, "fr"); +registerLocaleData(localeGl, "gl"); registerLocaleData(localeHe, "he"); registerLocaleData(localeHi, "hi"); registerLocaleData(localeHr, "hr"); @@ -88,12 +98,17 @@ registerLocaleData(localeKa, "ka"); registerLocaleData(localeKm, "km"); registerLocaleData(localeKn, "kn"); registerLocaleData(localeKo, "ko"); +registerLocaleData(localeLt, "lt"); registerLocaleData(localeLv, "lv"); registerLocaleData(localeMe, "me"); registerLocaleData(localeMl, "ml"); +registerLocaleData(localeMr, "mr"); +registerLocaleData(localeMy, "my"); registerLocaleData(localeNb, "nb"); +registerLocaleData(localeNe, "ne"); registerLocaleData(localeNl, "nl"); registerLocaleData(localeNn, "nn"); +registerLocaleData(localeOr, "or"); registerLocaleData(localePl, "pl"); registerLocaleData(localePtBr, "pt-BR"); registerLocaleData(localePtPt, "pt-PT"); @@ -104,6 +119,7 @@ registerLocaleData(localeSk, "sk"); registerLocaleData(localeSl, "sl"); registerLocaleData(localeSr, "sr"); registerLocaleData(localeSv, "sv"); +registerLocaleData(localeTe, "te"); registerLocaleData(localeTh, "th"); registerLocaleData(localeTr, "tr"); registerLocaleData(localeUk, "uk"); diff --git a/apps/web/src/app/shared/locales.ts b/apps/web/src/app/shared/locales.ts index eddf662a0af..409ad58d3d9 100644 --- a/apps/web/src/app/shared/locales.ts +++ b/apps/web/src/app/shared/locales.ts @@ -8,6 +8,7 @@ import localeBn from "@angular/common/locales/bn"; import localeBs from "@angular/common/locales/bs"; import localeCa from "@angular/common/locales/ca"; import localeCs from "@angular/common/locales/cs"; +import localeCy from "@angular/common/locales/cy"; import localeDa from "@angular/common/locales/da"; import localeDe from "@angular/common/locales/de"; import localeEl from "@angular/common/locales/el"; @@ -17,9 +18,11 @@ import localeEo from "@angular/common/locales/eo"; import localeEs from "@angular/common/locales/es"; import localeEt from "@angular/common/locales/et"; import localeEu from "@angular/common/locales/eu"; +import localeFa from "@angular/common/locales/fa"; import localeFi from "@angular/common/locales/fi"; import localeFil from "@angular/common/locales/fil"; import localeFr from "@angular/common/locales/fr"; +import localeGl from "@angular/common/locales/gl"; import localeHe from "@angular/common/locales/he"; import localeHi from "@angular/common/locales/hi"; import localeHr from "@angular/common/locales/hr"; @@ -33,9 +36,13 @@ import localeKn from "@angular/common/locales/kn"; import localeKo from "@angular/common/locales/ko"; import localeLv from "@angular/common/locales/lv"; import localeMl from "@angular/common/locales/ml"; +import localeMr from "@angular/common/locales/mr"; +import localeMy from "@angular/common/locales/my"; import localeNb from "@angular/common/locales/nb"; +import localeNe from "@angular/common/locales/ne"; import localeNl from "@angular/common/locales/nl"; import localeNn from "@angular/common/locales/nn"; +import localeOr from "@angular/common/locales/or"; import localePl from "@angular/common/locales/pl"; import localePtBr from "@angular/common/locales/pt"; import localePtPt from "@angular/common/locales/pt-PT"; @@ -46,6 +53,8 @@ import localeSk from "@angular/common/locales/sk"; import localeSl from "@angular/common/locales/sl"; import localeSr from "@angular/common/locales/sr"; import localeSv from "@angular/common/locales/sv"; +import localeTe from "@angular/common/locales/te"; +import localeTh from "@angular/common/locales/th"; import localeTr from "@angular/common/locales/tr"; import localeUk from "@angular/common/locales/uk"; import localeVi from "@angular/common/locales/vi"; @@ -61,6 +70,7 @@ registerLocaleData(localeBn, "bn"); registerLocaleData(localeBs, "bs"); registerLocaleData(localeCa, "ca"); registerLocaleData(localeCs, "cs"); +registerLocaleData(localeCy, "cy"); registerLocaleData(localeDa, "da"); registerLocaleData(localeDe, "de"); registerLocaleData(localeEl, "el"); @@ -70,9 +80,11 @@ registerLocaleData(localeEo, "eo"); registerLocaleData(localeEs, "es"); registerLocaleData(localeEt, "et"); registerLocaleData(localeEu, "eu"); +registerLocaleData(localeFa, "fa"); registerLocaleData(localeFi, "fi"); registerLocaleData(localeFil, "fil"); registerLocaleData(localeFr, "fr"); +registerLocaleData(localeGl, "gl"); registerLocaleData(localeHe, "he"); registerLocaleData(localeHi, "hi"); registerLocaleData(localeHr, "hr"); @@ -86,9 +98,13 @@ registerLocaleData(localeKn, "kn"); registerLocaleData(localeKo, "ko"); registerLocaleData(localeLv, "lv"); registerLocaleData(localeMl, "ml"); +registerLocaleData(localeMr, "mr"); +registerLocaleData(localeMy, "my"); registerLocaleData(localeNb, "nb"); +registerLocaleData(localeNe, "ne"); registerLocaleData(localeNl, "nl"); registerLocaleData(localeNn, "nn"); +registerLocaleData(localeOr, "or"); registerLocaleData(localePl, "pl"); registerLocaleData(localePtBr, "pt-BR"); registerLocaleData(localePtPt, "pt-PT"); @@ -99,6 +115,8 @@ registerLocaleData(localeSk, "sk"); registerLocaleData(localeSl, "sl"); registerLocaleData(localeSr, "sr"); registerLocaleData(localeSv, "sv"); +registerLocaleData(localeTe, "te"); +registerLocaleData(localeTh, "th"); registerLocaleData(localeTr, "tr"); registerLocaleData(localeUk, "uk"); registerLocaleData(localeVi, "vi"); From 087d174194453c9eca6e28ed95facdc5998ab205 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Mon, 18 Mar 2024 12:53:08 -0400 Subject: [PATCH 27/41] Scanning (#8079) * Scanning * Indicate test code * Set test inclusions correctly * Provide full depth for Sonar scans * Latest standards --- .github/workflows/scan.yml | 72 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/workflows/scan.yml diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml new file mode 100644 index 00000000000..ea9e69226ad --- /dev/null +++ b/.github/workflows/scan.yml @@ -0,0 +1,72 @@ +name: Scan + +on: + workflow_dispatch: + push: + branches: + - "main" + - "rc" + - "hotfix-rc" + pull_request_target: + types: [opened, synchronize] + +permissions: read-all + +jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + + sast: + name: SAST scan + runs-on: ubuntu-22.04 + needs: check-run + permissions: + security-events: write + + steps: + - name: Check out repo + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Scan with Checkmarx + uses: checkmarx/ast-github-action@749fec53e0db0f6404a97e2e0807c3e80e3583a7 #2.0.23 + env: + INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" + with: + project_name: ${{ github.repository }} + cx_tenant: ${{ secrets.CHECKMARX_TENANT }} + base_uri: https://ast.checkmarx.net/ + cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} + cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} + additional_params: --report-format sarif --output-path . ${{ env.INCREMENTAL }} + + - name: Upload Checkmarx results to GitHub + uses: github/codeql-action/upload-sarif@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6 + with: + sarif_file: cx_result.sarif + + quality: + name: Quality scan + runs-on: ubuntu-22.04 + needs: check-run + + steps: + - name: Check out repo + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Scan with SonarCloud + uses: sonarsource/sonarcloud-github-action@49e6cd3b187936a73b8280d59ffd9da69df63ec9 # v2.1.1 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: > + -Dsonar.organization=${{ github.repository_owner }} + -Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }} + -Dsonar.test.inclusions=**/*.spec.ts + -Dsonar.tests=. From c7abdb98797c9b793ab1eac2e5fc445b615b07ec Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Mon, 18 Mar 2024 11:58:33 -0500 Subject: [PATCH 28/41] Migrate OrganizationService to StateProvider (#7895) --- .../organization-service.factory.ts | 13 +- .../services/browser-organization.service.ts | 12 - .../browser/src/background/main.background.ts | 7 +- .../src/popup/services/services.module.ts | 8 - .../components/vault/current-tab.component.ts | 2 +- .../components/vault/vault-items.component.ts | 2 +- apps/cli/src/bw.ts | 2 +- apps/desktop/src/app/app.component.ts | 2 + .../organizations/guards/is-paid-org.guard.ts | 2 +- .../guards/org-permissions.guard.spec.ts | 12 +- .../guards/org-permissions.guard.ts | 2 +- .../guards/org-redirect.guard.ts | 2 +- .../organizations/members/people.component.ts | 2 +- .../reporting/reports-home.component.ts | 4 +- .../settings/two-factor-setup.component.ts | 13 +- apps/web/src/app/app.component.ts | 4 +- .../organization-plans.component.ts | 2 +- ...ganization-subscription-cloud.component.ts | 2 +- ...ization-subscription-selfhost.component.ts | 2 +- .../collection-dialog.component.ts | 6 +- .../vault/individual-vault/vault.component.ts | 4 +- .../guards/sm-org-enabled.guard.ts | 2 +- .../layout/navigation.component.ts | 2 +- .../overview/overview.component.ts | 3 +- .../project/project-secrets.component.ts | 4 +- .../projects/project/project.component.ts | 19 +- .../projects/projects/projects.component.ts | 4 +- .../secrets/dialog/secret-dialog.component.ts | 2 +- .../secrets/secrets.component.ts | 4 +- .../service-accounts.component.ts | 4 +- .../access-policy-selector.service.spec.ts | 22 +- .../access-policy-selector.service.ts | 2 +- .../shared/new-menu.component.ts | 19 +- .../shared/org-suspended.component.ts | 5 +- .../src/services/jslib-services.module.ts | 2 +- .../export-scope-callout.component.ts | 4 +- .../organization.service.abstraction.ts | 62 ++- .../models/domain/organization.ts | 4 + .../organization/organization.service.spec.ts | 376 ++++++++++++------ .../organization/organization.service.ts | 204 +++++----- .../platform/abstractions/state.service.ts | 12 - .../src/platform/models/domain/account.ts | 2 - .../src/platform/services/state.service.ts | 27 -- libs/common/src/state-migrations/migrate.ts | 6 +- ...ganization-state-to-state-provider.spec.ts | 183 +++++++++ ...ve-organization-state-to-state-provider.ts | 148 +++++++ .../src/components/import.component.ts | 8 +- 47 files changed, 855 insertions(+), 380 deletions(-) delete mode 100644 apps/browser/src/admin-console/services/browser-organization.service.ts create mode 100644 libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts diff --git a/apps/browser/src/admin-console/background/service-factories/organization-service.factory.ts b/apps/browser/src/admin-console/background/service-factories/organization-service.factory.ts index c77508b0f88..b7f6f98ea23 100644 --- a/apps/browser/src/admin-console/background/service-factories/organization-service.factory.ts +++ b/apps/browser/src/admin-console/background/service-factories/organization-service.factory.ts @@ -1,4 +1,5 @@ import { OrganizationService as AbstractOrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service"; import { FactoryOptions, @@ -6,11 +7,7 @@ import { factory, } from "../../../platform/background/service-factories/factory-options"; import { stateProviderFactory } from "../../../platform/background/service-factories/state-provider.factory"; -import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../../platform/background/service-factories/state-service.factory"; -import { BrowserOrganizationService } from "../../services/browser-organization.service"; +import { StateServiceInitOptions } from "../../../platform/background/service-factories/state-service.factory"; type OrganizationServiceFactoryOptions = FactoryOptions; @@ -25,10 +22,6 @@ export function organizationServiceFactory( cache, "organizationService", opts, - async () => - new BrowserOrganizationService( - await stateServiceFactory(cache, opts), - await stateProviderFactory(cache, opts), - ), + async () => new OrganizationService(await stateProviderFactory(cache, opts)), ); } diff --git a/apps/browser/src/admin-console/services/browser-organization.service.ts b/apps/browser/src/admin-console/services/browser-organization.service.ts deleted file mode 100644 index 6294756cdf7..00000000000 --- a/apps/browser/src/admin-console/services/browser-organization.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { BehaviorSubject } from "rxjs"; - -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service"; - -import { browserSession, sessionSync } from "../../platform/decorators/session-sync-observable"; - -@browserSession -export class BrowserOrganizationService extends OrganizationService { - @sessionSync({ initializer: Organization.fromJSON, initializeAs: "array" }) - protected _organizations: BehaviorSubject; -} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 814b1cdb668..4979ee6838e 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -20,6 +20,7 @@ import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin- import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService as InternalPolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service"; import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; @@ -182,7 +183,6 @@ import { VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; -import { BrowserOrganizationService } from "../admin-console/services/browser-organization.service"; import ContextMenusBackground from "../autofill/background/context-menus.background"; import NotificationBackground from "../autofill/background/notification.background"; import OverlayBackground from "../autofill/background/overlay.background"; @@ -502,10 +502,7 @@ export default class MainBackground { this.stateProvider, ); this.syncNotifierService = new SyncNotifierService(); - this.organizationService = new BrowserOrganizationService( - this.stateService, - this.stateProvider, - ); + this.organizationService = new OrganizationService(this.stateProvider); this.policyService = new PolicyService(this.stateProvider, this.organizationService); this.autofillSettingsService = new AutofillSettingsService( this.stateProvider, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 0e9cee5c671..7afd9df41e2 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -98,7 +98,6 @@ import { DialogService } from "@bitwarden/components"; import { ImportServiceAbstraction } from "@bitwarden/importer/core"; import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; -import { BrowserOrganizationService } from "../../admin-console/services/browser-organization.service"; import { UnauthGuardService } from "../../auth/popup/services"; import { AutofillService } from "../../autofill/services/abstractions/autofill.service"; import MainBackground from "../../background/main.background"; @@ -398,13 +397,6 @@ function getBgService(service: keyof MainBackground) { useFactory: getBgService("notificationsService"), deps: [], }, - { - provide: OrganizationService, - useFactory: (stateService: StateServiceAbstraction, stateProvider: StateProvider) => { - return new BrowserOrganizationService(stateService, stateProvider); - }, - deps: [StateServiceAbstraction, StateProvider], - }, { provide: VaultFilterService, useClass: VaultFilterService, diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts index e4fdc7525e8..c2817ed50a4 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts @@ -275,7 +275,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy { const dontShowIdentities = !(await firstValueFrom( this.vaultSettingsService.showIdentitiesCurrentTab$, )); - this.showOrganizations = this.organizationService.hasOrganizations(); + this.showOrganizations = await this.organizationService.hasOrganizations(); if (!dontShowCards) { otherTypes.push(CipherType.Card); } diff --git a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts index 60a8f4b78e7..96d5fe170b0 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts @@ -74,7 +74,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn async ngOnInit() { this.searchTypeSearch = !this.platformUtilsService.isSafari(); - this.showOrganizations = this.organizationService.hasOrganizations(); + this.showOrganizations = await this.organizationService.hasOrganizations(); this.vaultFilter = this.vaultFilterService.getVaultFilter(); // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (params) => { diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 29019961e2b..e2110006e88 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -410,7 +410,7 @@ export class Main { this.providerService = new ProviderService(this.stateProvider); - this.organizationService = new OrganizationService(this.stateService, this.stateProvider); + this.organizationService = new OrganizationService(this.stateProvider); this.organizationUserService = new OrganizationUserServiceImplementation(this.apiService); diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index a59b07241d9..574dce93908 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -22,6 +22,7 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; +import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -153,6 +154,7 @@ export class AppComponent implements OnInit, OnDestroy { private biometricStateService: BiometricStateService, private stateEventRunnerService: StateEventRunnerService, private providerService: ProviderService, + private organizationService: InternalOrganizationServiceAbstraction, ) {} ngOnInit() { diff --git a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts index 16050a1555f..f6968daca9f 100644 --- a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts +++ b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts @@ -17,7 +17,7 @@ export class IsPaidOrgGuard implements CanActivate { ) {} async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { - const org = this.organizationService.get(route.params.organizationId); + const org = await this.organizationService.get(route.params.organizationId); if (org == null) { return this.router.createUrlTree(["/"]); diff --git a/apps/web/src/app/admin-console/organizations/guards/org-permissions.guard.spec.ts b/apps/web/src/app/admin-console/organizations/guards/org-permissions.guard.spec.ts index 4a03f6a8c1e..570c35d5f8f 100644 --- a/apps/web/src/app/admin-console/organizations/guards/org-permissions.guard.spec.ts +++ b/apps/web/src/app/admin-console/organizations/guards/org-permissions.guard.spec.ts @@ -66,7 +66,7 @@ describe("Organization Permissions Guard", () => { it("permits navigation if no permissions are specified", async () => { const org = orgFactory(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const actual = await organizationPermissionsGuard.canActivate(route, state); @@ -81,7 +81,7 @@ describe("Organization Permissions Guard", () => { }; const org = orgFactory(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const actual = await organizationPermissionsGuard.canActivate(route, state); @@ -104,7 +104,7 @@ describe("Organization Permissions Guard", () => { }); const org = orgFactory(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const actual = await organizationPermissionsGuard.canActivate(route, state); @@ -124,7 +124,7 @@ describe("Organization Permissions Guard", () => { }), }); const org = orgFactory(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const actual = await organizationPermissionsGuard.canActivate(route, state); @@ -141,7 +141,7 @@ describe("Organization Permissions Guard", () => { type: OrganizationUserType.Admin, enabled: false, }); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const actual = await organizationPermissionsGuard.canActivate(route, state); @@ -153,7 +153,7 @@ describe("Organization Permissions Guard", () => { type: OrganizationUserType.Owner, enabled: false, }); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const actual = await organizationPermissionsGuard.canActivate(route, state); diff --git a/apps/web/src/app/admin-console/organizations/guards/org-permissions.guard.ts b/apps/web/src/app/admin-console/organizations/guards/org-permissions.guard.ts index 76df518cf7a..63b89284ec6 100644 --- a/apps/web/src/app/admin-console/organizations/guards/org-permissions.guard.ts +++ b/apps/web/src/app/admin-console/organizations/guards/org-permissions.guard.ts @@ -28,7 +28,7 @@ export class OrganizationPermissionsGuard implements CanActivate { await this.syncService.fullSync(false); } - const org = this.organizationService.get(route.params.organizationId); + const org = await this.organizationService.get(route.params.organizationId); if (org == null) { return this.router.createUrlTree(["/"]); } diff --git a/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.ts b/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.ts index 61d79f8bd5d..bbfb51ed949 100644 --- a/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.ts +++ b/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.ts @@ -16,7 +16,7 @@ export class OrganizationRedirectGuard implements CanActivate { ) {} async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { - const org = this.organizationService.get(route.params.organizationId); + const org = await this.organizationService.get(route.params.organizationId); const customRedirect = route.data?.autoRedirectCallback; if (customRedirect) { diff --git a/apps/web/src/app/admin-console/organizations/members/people.component.ts b/apps/web/src/app/admin-console/organizations/members/people.component.ts index afe07aaad1b..4e45f70b6dd 100644 --- a/apps/web/src/app/admin-console/organizations/members/people.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/people.component.ts @@ -143,7 +143,7 @@ export class PeopleComponent async ngOnInit() { const organization$ = this.route.params.pipe( - map((params) => this.organizationService.get(params.organizationId)), + concatMap((params) => this.organizationService.get$(params.organizationId)), shareReplay({ refCount: true, bufferSize: 1 }), ); diff --git a/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts b/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts index 4e4bea2e0cb..69b9b78819f 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute, NavigationEnd, Router } from "@angular/router"; -import { filter, map, Observable, startWith } from "rxjs"; +import { filter, map, Observable, startWith, concatMap } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -28,7 +28,7 @@ export class ReportsHomeComponent implements OnInit { ); this.reports$ = this.route.params.pipe( - map((params) => this.organizationService.get(params.organizationId)), + concatMap((params) => this.organizationService.get$(params.organizationId)), map((org) => this.buildReports(org.isFreeOrg)), ); } diff --git a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts index f96c1537bd2..abf1d249e16 100644 --- a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts @@ -1,6 +1,6 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, takeUntil } from "rxjs"; +import { concatMap, takeUntil, map } from "rxjs"; import { tap } from "rxjs/operators"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -42,9 +42,14 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent { async ngOnInit() { this.route.params .pipe( - tap((params) => { - this.organizationId = params.organizationId; - this.organization = this.organizationService.get(this.organizationId); + concatMap((params) => + this.organizationService + .get$(params.organizationId) + .pipe(map((organization) => ({ params, organization }))), + ), + tap(async (mapResponse) => { + this.organizationId = mapResponse.params.organizationId; + this.organization = mapResponse.organization; }), concatMap(async () => await super.ngOnInit()), takeUntil(this.destroy$), diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 83900cd85db..9d6ff91666c 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -11,7 +11,7 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notificatio import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; @@ -92,7 +92,7 @@ export class AppComponent implements OnDestroy, OnInit { private biometricStateService: BiometricStateService, private stateEventRunnerService: StateEventRunnerService, private paymentMethodWarningService: PaymentMethodWarningService, - private organizationService: OrganizationService, + private organizationService: InternalOrganizationServiceAbstraction, ) {} ngOnInit() { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index b7070be1cfb..1242018673f 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -150,7 +150,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { async ngOnInit() { if (this.organizationId) { - this.organization = this.organizationService.get(this.organizationId); + this.organization = await this.organizationService.get(this.organizationId); this.billing = await this.organizationApiService.getBilling(this.organizationId); this.sub = await this.organizationApiService.getSubscription(this.organizationId); } diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 45c7ea1a210..24374ee8969 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -94,7 +94,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return; } this.loading = true; - this.userOrg = this.organizationService.get(this.organizationId); + this.userOrg = await this.organizationService.get(this.organizationId); if (this.userOrg.canViewSubscription) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); this.lineItems = this.sub?.subscription?.items; diff --git a/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.ts index 5a9a8eb0812..1b66f5400af 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.ts @@ -110,7 +110,7 @@ export class OrganizationSubscriptionSelfhostComponent implements OnInit, OnDest return; } this.loading = true; - this.userOrg = this.organizationService.get(this.organizationId); + this.userOrg = await this.organizationService.get(this.organizationId); if (this.userOrg.canViewSubscription) { const subscriptionResponse = await this.organizationApiService.getSubscription( this.organizationId, diff --git a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts index b10d6b1e28e..357d2217e47 100644 --- a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts @@ -139,9 +139,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { } async loadOrg(orgId: string, collectionIds: string[]) { - const organization$ = of(this.organizationService.get(orgId)).pipe( - shareReplay({ refCount: true, bufferSize: 1 }), - ); + const organization$ = this.organizationService + .get$(orgId) + .pipe(shareReplay({ refCount: true, bufferSize: 1 })); const groups$ = organization$.pipe( switchMap((organization) => { if (!organization.useGroups) { 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 2a29eabc4fa..d7d9ab8074a 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -518,7 +518,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.messagingService.send("premiumRequired"); return; } else if (cipher.organizationId != null) { - const org = this.organizationService.get(cipher.organizationId); + const org = await this.organizationService.get(cipher.organizationId); if (org != null && (org.maxStorageGb == null || org.maxStorageGb === 0)) { this.messagingService.send("upgradeOrganization", { organizationId: cipher.organizationId, @@ -697,7 +697,7 @@ export class VaultComponent implements OnInit, OnDestroy { } async deleteCollection(collection: CollectionView): Promise { - const organization = this.organizationService.get(collection.organizationId); + const organization = await this.organizationService.get(collection.organizationId); if (!collection.canDelete(organization)) { this.platformUtilsService.showToast( "error", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/guards/sm-org-enabled.guard.ts b/bitwarden_license/bit-web/src/app/secrets-manager/guards/sm-org-enabled.guard.ts index 3ff4d998a3d..a1f7564156c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/guards/sm-org-enabled.guard.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/guards/sm-org-enabled.guard.ts @@ -16,7 +16,7 @@ export const organizationEnabledGuard: CanActivateFn = async (route: ActivatedRo await syncService.fullSync(false); } - const org = orgService.get(route.params.organizationId); + const org = await orgService.get(route.params.organizationId); if (org == null || !org.canAccessSecretsManager) { return createUrlTreeFromSnapshot(route, ["/"]); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts index 90faf165381..cd117819a9f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts @@ -14,7 +14,7 @@ export class NavigationComponent { protected readonly logo = SecretsManagerLogo; protected orgFilter = (org: Organization) => org.canAccessSecretsManager; protected isAdmin$ = this.route.params.pipe( - map((params) => this.organizationService.get(params.organizationId)?.isAdmin), + map(async (params) => (await this.organizationService.get(params.organizationId))?.isAdmin), ); constructor( diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index 8dff4cf92f8..95c17642538 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -12,6 +12,7 @@ import { take, share, firstValueFrom, + concatMap, } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -105,7 +106,7 @@ export class OverviewComponent implements OnInit, OnDestroy { orgId$ .pipe( - map((orgId) => this.organizationService.get(orgId)), + concatMap(async (orgId) => await this.organizationService.get(orgId)), takeUntil(this.destroy$), ) .subscribe((org) => { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts index ebfb006c0ad..07d50b28ee1 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts @@ -63,7 +63,9 @@ export class ProjectSecretsComponent { switchMap(async ([_, params]) => { this.organizationId = params.organizationId; this.projectId = params.projectId; - this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; + this.organizationEnabled = ( + await this.organizationService.get(params.organizationId) + )?.enabled; return await this.getSecretsByProject(); }), ); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts index d24ff7a3b09..742c2bea1d8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts @@ -10,6 +10,8 @@ import { Subject, switchMap, takeUntil, + map, + concatMap, } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -70,11 +72,18 @@ export class ProjectComponent implements OnInit, OnDestroy { }), ); - this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params) => { - this.organizationId = params.organizationId; - this.projectId = params.projectId; - this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; - }); + const projectId$ = this.route.params.pipe(map((p) => p.projectId)); + const organization$ = this.route.params.pipe( + concatMap((params) => this.organizationService.get$(params.organizationId)), + ); + + combineLatest([projectId$, organization$]) + .pipe(takeUntil(this.destroy$)) + .subscribe(([projectId, organization]) => { + this.organizationId = organization.id; + this.projectId = projectId; + this.organizationEnabled = organization.enabled; + }); } ngOnDestroy(): void { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts index 83541a37697..831ee4df9bc 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts @@ -51,7 +51,9 @@ export class ProjectsComponent implements OnInit { ]).pipe( switchMap(async ([params]) => { this.organizationId = params.organizationId; - this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; + this.organizationEnabled = ( + await this.organizationService.get(params.organizationId) + )?.enabled; return await this.getProjects(); }), diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts index e944fbce0c7..b1bd91a04fb 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts @@ -87,7 +87,7 @@ export class SecretDialogComponent implements OnInit { this.formGroup.get("project").setValue(this.data.projectId); } - if (this.organizationService.get(this.data.organizationId)?.isAdmin) { + if ((await this.organizationService.get(this.data.organizationId))?.isAdmin) { this.formGroup.get("project").removeValidators(Validators.required); this.formGroup.get("project").updateValueAndValidity(); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts index 64bbf479d88..a7413c9b59f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts @@ -47,7 +47,9 @@ export class SecretsComponent implements OnInit { combineLatestWith(this.route.params), switchMap(async ([_, params]) => { this.organizationId = params.organizationId; - this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; + this.organizationEnabled = ( + await this.organizationService.get(params.organizationId) + )?.enabled; return await this.getSecrets(); }), diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts index a5e3cd29d24..d015cccd99d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts @@ -46,7 +46,9 @@ export class ServiceAccountsComponent implements OnInit { ]).pipe( switchMap(async ([params]) => { this.organizationId = params.organizationId; - this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; + this.organizationEnabled = ( + await this.organizationService.get(params.organizationId) + )?.enabled; return await this.getServiceAccounts(); }), diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.service.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.service.spec.ts index 482d2bb06b4..8c0f9c37311 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.service.spec.ts @@ -26,7 +26,7 @@ describe("AccessPolicySelectorService", () => { describe("showAccessRemovalWarning", () => { it("returns false when current user is admin", async () => { const org = orgFactory(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = []; @@ -38,7 +38,7 @@ describe("AccessPolicySelectorService", () => { it("returns false when current user is owner", async () => { const org = orgFactory(); org.type = OrganizationUserType.Owner; - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = []; @@ -49,7 +49,7 @@ describe("AccessPolicySelectorService", () => { it("returns true when current user isn't owner/admin and all policies are removed", async () => { const org = setupUserOrg(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = []; @@ -60,7 +60,7 @@ describe("AccessPolicySelectorService", () => { it("returns true when current user isn't owner/admin and user policy is set to canRead", async () => { const org = setupUserOrg(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = []; selectedPolicyValues.push( @@ -77,7 +77,7 @@ describe("AccessPolicySelectorService", () => { it("returns false when current user isn't owner/admin and user policy is set to canReadWrite", async () => { const org = setupUserOrg(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = [ createApItemValueType({ @@ -93,7 +93,7 @@ describe("AccessPolicySelectorService", () => { it("returns true when current user isn't owner/admin and a group Read policy is submitted that the user is a member of", async () => { const org = setupUserOrg(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = [ createApItemValueType({ @@ -111,7 +111,7 @@ describe("AccessPolicySelectorService", () => { it("returns false when current user isn't owner/admin and a group ReadWrite policy is submitted that the user is a member of", async () => { const org = setupUserOrg(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = [ createApItemValueType({ @@ -129,7 +129,7 @@ describe("AccessPolicySelectorService", () => { it("returns true when current user isn't owner/admin and a group ReadWrite policy is submitted that the user is not a member of", async () => { const org = setupUserOrg(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = [ createApItemValueType({ @@ -147,7 +147,7 @@ describe("AccessPolicySelectorService", () => { it("returns false when current user isn't owner/admin, user policy is set to CanRead, and user is in read write group", async () => { const org = setupUserOrg(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = [ createApItemValueType({ @@ -169,7 +169,7 @@ describe("AccessPolicySelectorService", () => { it("returns true when current user isn't owner/admin, user policy is set to CanRead, and user is not in ReadWrite group", async () => { const org = setupUserOrg(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = [ createApItemValueType({ @@ -191,7 +191,7 @@ describe("AccessPolicySelectorService", () => { it("returns true when current user isn't owner/admin, user policy is set to CanRead, and user is in Read group", async () => { const org = setupUserOrg(); - organizationService.get.calledWith(org.id).mockReturnValue(org); + organizationService.get.calledWith(org.id).mockResolvedValue(org); const selectedPolicyValues: ApItemValueType[] = [ createApItemValueType({ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.service.ts index 4a90172d45b..b219bfd33d2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.service.ts @@ -17,7 +17,7 @@ export class AccessPolicySelectorService { organizationId: string, selectedPoliciesValues: ApItemValueType[], ): Promise { - const organization = this.organizationService.get(organizationId); + const organization = await this.organizationService.get(organizationId); if (organization.isOwner || organization.isAdmin) { return false; } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts index 6cb5722be04..d2533d8dbf7 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { Subject, takeUntil, concatMap, map } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { DialogService } from "@bitwarden/components"; @@ -34,10 +34,19 @@ export class NewMenuComponent implements OnInit, OnDestroy { ) {} ngOnInit() { - this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => { - this.organizationId = params.organizationId; - this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; - }); + this.route.params + .pipe( + concatMap((params) => + this.organizationService + .get$(params.organizationId) + .pipe(map((organization) => ({ params, organization }))), + ), + takeUntil(this.destroy$), + ) + .subscribe((mapResult) => { + this.organizationId = mapResult?.params?.organizationId; + this.organizationEnabled = mapResult?.organization?.enabled; + }); } ngOnDestroy(): void { 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 ee94a78bb83..1683d447906 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 @@ -1,6 +1,6 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { map } from "rxjs"; +import { map, concatMap } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Icon, Icons } from "@bitwarden/components"; @@ -16,6 +16,7 @@ export class OrgSuspendedComponent { protected NoAccess: Icon = Icons.NoAccess; protected organizationName$ = this.route.params.pipe( - map((params) => this.organizationService.get(params.organizationId)?.name), + concatMap((params) => this.organizationService.get$(params.organizationId)), + map((org) => org?.name), ); } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index b153c8219b3..cc4af9b2071 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -778,7 +778,7 @@ const typesafeProviders: Array = [ safeProvider({ provide: InternalOrganizationServiceAbstraction, useClass: OrganizationService, - deps: [StateServiceAbstraction, StateProvider], + deps: [StateProvider], }), safeProvider({ provide: OrganizationServiceAbstraction, diff --git a/libs/angular/src/tools/export/components/export-scope-callout.component.ts b/libs/angular/src/tools/export/components/export-scope-callout.component.ts index 48651d822ab..545dfe4560a 100644 --- a/libs/angular/src/tools/export/components/export-scope-callout.component.ts +++ b/libs/angular/src/tools/export/components/export-scope-callout.component.ts @@ -34,7 +34,7 @@ export class ExportScopeCalloutComponent implements OnInit { ) {} async ngOnInit(): Promise { - if (!this.organizationService.hasOrganizations()) { + if (!(await this.organizationService.hasOrganizations())) { return; } @@ -48,7 +48,7 @@ export class ExportScopeCalloutComponent implements OnInit { ? { title: "exportingOrganizationVaultTitle", description: "exportingOrganizationVaultDesc", - scopeIdentifier: this.organizationService.get(organizationId).name, + scopeIdentifier: (await this.organizationService.get(organizationId)).name, } : { title: "exportingPersonalVaultTitle", diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 1ef0430f958..9cc4bba0eb0 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -2,6 +2,7 @@ import { map, Observable } from "rxjs"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { Utils } from "../../../platform/misc/utils"; +import { UserId } from "../../../types/guid"; import { OrganizationData } from "../../models/data/organization.data"; import { Organization } from "../../models/domain/organization"; @@ -86,34 +87,67 @@ export function canAccessImport(i18nService: I18nService) { /** * Returns `true` if a user is a member of an organization (rather than only being a ProviderUser) - * @deprecated Use organizationService.memberOrganizations$ instead + * @deprecated Use organizationService.organizations$ with a filter instead */ export function isMember(org: Organization): boolean { return org.isMember; } +/** + * Publishes an observable stream of organizations. This service is meant to + * be used widely across Bitwarden as the primary way of fetching organizations. + * Risky operations like updates are isolated to the + * internal extension `InternalOrganizationServiceAbstraction`. + */ export abstract class OrganizationService { + /** + * Publishes state for all organizations under the active user. + * @returns An observable list of organizations + */ organizations$: Observable; - /** - * Organizations that the user is a member of (excludes organizations that they only have access to via a provider) - */ + // @todo Clean these up. Continuing to expand them is not recommended. + // @see https://bitwarden.atlassian.net/browse/AC-2252 memberOrganizations$: Observable; - - get$: (id: string) => Observable; - get: (id: string) => Organization; - getByIdentifier: (identifier: string) => Organization; - getAll: (userId?: string) => Promise; /** - * @deprecated For the CLI only - * @param id id of the organization + * @deprecated This is currently only used in the CLI, and should not be + * used in any new calls. Use get$ instead for the time being, and we'll be + * removing this method soon. See Jira for details: + * https://bitwarden.atlassian.net/browse/AC-2252. */ getFromState: (id: string) => Promise; canManageSponsorships: () => Promise; - hasOrganizations: () => boolean; + hasOrganizations: () => Promise; + get$: (id: string) => Observable; + get: (id: string) => Promise; + getAll: (userId?: string) => Promise; + // } +/** + * Big scary buttons that **update** organization state. These should only be + * called from within admin-console scoped code. Extends the base + * `OrganizationService` for easy access to `get` calls. + * @internal + */ export abstract class InternalOrganizationServiceAbstraction extends OrganizationService { - replace: (organizations: { [id: string]: OrganizationData }) => Promise; - upsert: (OrganizationData: OrganizationData | OrganizationData[]) => Promise; + /** + * Replaces state for the provided organization, or creates it if not found. + * @param organization The organization state being saved. + * @param userId The userId to replace state for. Defaults to the active + * user. + */ + upsert: (OrganizationData: OrganizationData) => Promise; + + /** + * Replaces state for the entire registered organization list for the active user. + * You probably don't want this unless you're calling from a full sync + * operation or a logout. See `upsert` for creating & updating a single + * organization in the state. + * @param organizations A complete list of all organization state for the active + * user. + * @param userId The userId to replace state for. Defaults to the active + * user. + */ + replace: (organizations: { [id: string]: OrganizationData }, userId?: UserId) => Promise; } diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 8eba83ba3ef..18b762207a1 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -320,6 +320,10 @@ export class Organization { return !this.useTotp; } + get canManageSponsorships() { + return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null; + } + static fromJSON(json: Jsonify) { if (json == null) { return null; diff --git a/libs/common/src/admin-console/services/organization/organization.service.spec.ts b/libs/common/src/admin-console/services/organization/organization.service.spec.ts index a4b7e958886..908f4b8e28b 100644 --- a/libs/common/src/admin-console/services/organization/organization.service.spec.ts +++ b/libs/common/src/admin-console/services/organization/organization.service.spec.ts @@ -1,114 +1,142 @@ -import { MockProxy, mock, any, mockClear } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; import { FakeActiveUserState } from "../../../../spec/fake-state"; -import { StateService } from "../../../platform/abstractions/state.service"; import { Utils } from "../../../platform/misc/utils"; -import { UserId } from "../../../types/guid"; +import { OrganizationId, UserId } from "../../../types/guid"; import { OrganizationData } from "../../models/data/organization.data"; +import { Organization } from "../../models/domain/organization"; import { OrganizationService, ORGANIZATIONS } from "./organization.service"; -describe("Organization Service", () => { +describe("OrganizationService", () => { let organizationService: OrganizationService; - let stateService: MockProxy; - let activeAccount: BehaviorSubject; - let activeAccountUnlocked: BehaviorSubject; + const fakeUserId = Utils.newGuid() as UserId; + let fakeAccountService: FakeAccountService; + let fakeStateProvider: FakeStateProvider; + let fakeActiveUserState: FakeActiveUserState>; - const mockUserId = Utils.newGuid() as UserId; - let accountService: FakeAccountService; - let stateProvider: FakeStateProvider; - let activeUserOrganizationsState: FakeActiveUserState>; - - const resetStateService = async ( - customizeStateService: (stateService: MockProxy) => void, - ) => { - mockClear(stateService); - stateService = mock(); - stateService.activeAccount$ = activeAccount; - stateService.activeAccountUnlocked$ = activeAccountUnlocked; - customizeStateService(stateService); - organizationService = new OrganizationService(stateService, stateProvider); - await new Promise((r) => setTimeout(r, 50)); - }; - - function prepareStateProvider(): void { - accountService = mockAccountServiceWith(mockUserId); - stateProvider = new FakeStateProvider(accountService); + /** + * It is easier to read arrays than records in code, but we store a record + * in state. This helper methods lets us build organization arrays in tests + * and easily map them to records before storing them in state. + */ + function arrayToRecord(input: OrganizationData[]): Record { + if (input == null) { + return undefined; + } + return Object.fromEntries(input?.map((i) => [i.id, i])); } - function seedTestData(): void { - activeUserOrganizationsState = stateProvider.activeUser.getFake(ORGANIZATIONS); - activeUserOrganizationsState.nextState({ "1": organizationData("1", "Test Org") }); + /** + * There are a few assertions in this spec that check for array equality + * but want to ignore a specific index that _should_ be different. This + * function takes two arrays, and an index. It checks for equality of the + * arrays, but splices out the specified index from both arrays first. + */ + function expectIsEqualExceptForIndex(x: any[], y: any[], indexToExclude: number) { + // Clone the arrays to avoid modifying the reference values + const a = [...x]; + const b = [...y]; + delete a[indexToExclude]; + delete b[indexToExclude]; + expect(a).toEqual(b); } - beforeEach(() => { - activeAccount = new BehaviorSubject("123"); - activeAccountUnlocked = new BehaviorSubject(true); + /** + * Builds a simple mock `OrganizationData[]` array that can be used in tests + * to populate state. + * @param count The number of organizations to populate the list with. The + * function returns undefined if this is less than 1. The default value is 1. + * @param suffix A string to append to data fields on each organization. + * This defaults to the index of the organization in the list. + * @returns an `OrganizationData[]` array that can be used to populate + * stateProvider. + */ + function buildMockOrganizations(count = 1, suffix?: string): OrganizationData[] { + if (count < 1) { + return undefined; + } - stateService = mock(); - stateService.activeAccount$ = activeAccount; - stateService.activeAccountUnlocked$ = activeAccountUnlocked; + function buildMockOrganization(id: OrganizationId, name: string, identifier: string) { + const data = new OrganizationData({} as any, {} as any); + data.id = id; + data.name = name; + data.identifier = identifier; - stateService.getOrganizations.calledWith(any()).mockResolvedValue({ - "1": organizationData("1", "Test Org"), - }); + return data; + } - prepareStateProvider(); + const mockOrganizations = []; + for (let i = 0; i < count; i++) { + const s = suffix ? suffix + i.toString() : i.toString(); + mockOrganizations.push( + buildMockOrganization(("org" + s) as OrganizationId, "org" + s, "orgIdentifier" + s), + ); + } - organizationService = new OrganizationService(stateService, stateProvider); + return mockOrganizations; + } - seedTestData(); - }); + /** + * `OrganizationService` deals with multiple accounts at times. This helper + * function can be used to add a new non-active account to the test data. + * This function is **not** needed to handle creation of the first account, + * as that is handled by the `FakeAccountService` in `mockAccountServiceWith()` + * @returns The `UserId` of the newly created state account and the mock data + * created for them as an `Organization[]`. + */ + async function addNonActiveAccountToStateProvider(): Promise<[UserId, OrganizationData[]]> { + const nonActiveUserId = Utils.newGuid() as UserId; - afterEach(() => { - activeAccount.complete(); - activeAccountUnlocked.complete(); + const mockOrganizations = buildMockOrganizations(10); + const fakeNonActiveUserState = fakeStateProvider.singleUser.getFake( + nonActiveUserId, + ORGANIZATIONS, + ); + fakeNonActiveUserState.nextState(arrayToRecord(mockOrganizations)); + + return [nonActiveUserId, mockOrganizations]; + } + + beforeEach(async () => { + fakeAccountService = mockAccountServiceWith(fakeUserId); + fakeStateProvider = new FakeStateProvider(fakeAccountService); + fakeActiveUserState = fakeStateProvider.activeUser.getFake(ORGANIZATIONS); + organizationService = new OrganizationService(fakeStateProvider); }); it("getAll", async () => { + const mockData: OrganizationData[] = buildMockOrganizations(1); + fakeActiveUserState.nextState(arrayToRecord(mockData)); const orgs = await organizationService.getAll(); expect(orgs).toHaveLength(1); const org = orgs[0]; - expect(org).toEqual({ - id: "1", - name: "Test Org", - identifier: "test", - }); + expect(org).toEqual(new Organization(mockData[0])); }); describe("canManageSponsorships", () => { it("can because one is available", async () => { - await resetStateService((stateService) => { - stateService.getOrganizations.mockResolvedValue({ - "1": { ...organizationData("1", "Org"), familySponsorshipAvailable: true }, - }); - }); - + const mockData: OrganizationData[] = buildMockOrganizations(1); + mockData[0].familySponsorshipAvailable = true; + fakeActiveUserState.nextState(arrayToRecord(mockData)); const result = await organizationService.canManageSponsorships(); expect(result).toBe(true); }); it("can because one is used", async () => { - await resetStateService((stateService) => { - stateService.getOrganizations.mockResolvedValue({ - "1": { ...organizationData("1", "Test Org"), familySponsorshipFriendlyName: "Something" }, - }); - }); - + const mockData: OrganizationData[] = buildMockOrganizations(1); + mockData[0].familySponsorshipFriendlyName = "Something"; + fakeActiveUserState.nextState(arrayToRecord(mockData)); const result = await organizationService.canManageSponsorships(); expect(result).toBe(true); }); it("can not because one isn't available or taken", async () => { - await resetStateService((stateService) => { - stateService.getOrganizations.mockResolvedValue({ - "1": { ...organizationData("1", "Org"), familySponsorshipFriendlyName: null }, - }); - }); - + const mockData: OrganizationData[] = buildMockOrganizations(1); + mockData[0].familySponsorshipFriendlyName = null; + fakeActiveUserState.nextState(arrayToRecord(mockData)); const result = await organizationService.canManageSponsorships(); expect(result).toBe(false); }); @@ -116,81 +144,181 @@ describe("Organization Service", () => { describe("get", () => { it("exists", async () => { - const result = organizationService.get("1"); - - expect(result).toEqual({ - id: "1", - name: "Test Org", - identifier: "test", - }); + const mockData = buildMockOrganizations(1); + fakeActiveUserState.nextState(arrayToRecord(mockData)); + const result = await organizationService.get(mockData[0].id); + expect(result).toEqual(new Organization(mockData[0])); }); it("does not exist", async () => { - const result = organizationService.get("2"); - + const result = await organizationService.get("this-org-does-not-exist"); expect(result).toBe(undefined); }); }); - it("upsert", async () => { - await organizationService.upsert(organizationData("2", "Test 2")); + describe("organizations$", () => { + describe("null checking behavior", () => { + it("publishes an empty array if organizations in state = undefined", async () => { + const mockData: OrganizationData[] = undefined; + fakeActiveUserState.nextState(arrayToRecord(mockData)); + const result = await firstValueFrom(organizationService.organizations$); + expect(result).toEqual([]); + }); - expect(await firstValueFrom(organizationService.organizations$)).toEqual([ - { - id: "1", - name: "Test Org", - identifier: "test", - }, - { - id: "2", - name: "Test 2", - identifier: "test", - }, - ]); - }); + it("publishes an empty array if organizations in state = null", async () => { + const mockData: OrganizationData[] = null; + fakeActiveUserState.nextState(arrayToRecord(mockData)); + const result = await firstValueFrom(organizationService.organizations$); + expect(result).toEqual([]); + }); - describe("getByIdentifier", () => { - it("exists", async () => { - const result = organizationService.getByIdentifier("test"); - - expect(result).toEqual({ - id: "1", - name: "Test Org", - identifier: "test", + it("publishes an empty array if organizations in state = []", async () => { + const mockData: OrganizationData[] = []; + fakeActiveUserState.nextState(arrayToRecord(mockData)); + const result = await firstValueFrom(organizationService.organizations$); + expect(result).toEqual([]); }); }); - it("does not exist", async () => { - const result = organizationService.getByIdentifier("blah"); + describe("parameter handling & returns", () => { + it("publishes all organizations for the active user by default", async () => { + const mockData = buildMockOrganizations(10); + fakeActiveUserState.nextState(arrayToRecord(mockData)); + const result = await firstValueFrom(organizationService.organizations$); + expect(result).toEqual(mockData); + }); - expect(result).toBeUndefined(); + it("can be used to publish the organizations of a non active user if requested", async () => { + const activeUserMockData = buildMockOrganizations(10, "activeUserState"); + fakeActiveUserState.nextState(arrayToRecord(activeUserMockData)); + + const [nonActiveUserId, nonActiveUserMockOrganizations] = + await addNonActiveAccountToStateProvider(); + // This can be updated to use + // `firstValueFrom(organizations$(nonActiveUserId)` once all the + // promise based methods are removed from `OrganizationService` and the + // main observable is refactored to accept a userId + const result = await organizationService.getAll(nonActiveUserId); + + expect(result).toEqual(nonActiveUserMockOrganizations); + expect(result).not.toEqual(await firstValueFrom(organizationService.organizations$)); + }); }); }); - describe("delete", () => { - it("exists", async () => { - await organizationService.delete("1"); - - expect(stateService.getOrganizations).toHaveBeenCalledTimes(2); - - expect(stateService.setOrganizations).toHaveBeenCalledTimes(1); + describe("upsert()", () => { + it("can create the organization list if necassary", async () => { + // Notice that no default state is provided in this test, so the list in + // `stateProvider` will be null when the `upsert` method is called. + const mockData = buildMockOrganizations(); + await organizationService.upsert(mockData[0]); + const result = await firstValueFrom(organizationService.organizations$); + expect(result).toEqual(mockData.map((x) => new Organization(x))); }); - it("does not exist", async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - organizationService.delete("1"); + it("updates an organization that already exists in state, defaulting to the active user", async () => { + const mockData = buildMockOrganizations(10); + fakeActiveUserState.nextState(arrayToRecord(mockData)); + const indexToUpdate = 5; + const anUpdatedOrganization = { + ...buildMockOrganizations(1, "UPDATED").pop(), + id: mockData[indexToUpdate].id, + }; + await organizationService.upsert(anUpdatedOrganization); + const result = await firstValueFrom(organizationService.organizations$); + expect(result[indexToUpdate]).not.toEqual(new Organization(mockData[indexToUpdate])); + expect(result[indexToUpdate].id).toEqual(new Organization(mockData[indexToUpdate]).id); + expectIsEqualExceptForIndex( + result, + mockData.map((x) => new Organization(x)), + indexToUpdate, + ); + }); - expect(stateService.getOrganizations).toHaveBeenCalledTimes(2); + it("can also update an organization in state for a non-active user, if requested", async () => { + const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations"); + fakeActiveUserState.nextState(arrayToRecord(activeUserMockData)); + + const [nonActiveUserId, nonActiveUserMockOrganizations] = + await addNonActiveAccountToStateProvider(); + const indexToUpdate = 5; + const anUpdatedOrganization = { + ...buildMockOrganizations(1, "UPDATED").pop(), + id: nonActiveUserMockOrganizations[indexToUpdate].id, + }; + + await organizationService.upsert(anUpdatedOrganization, nonActiveUserId); + // This can be updated to use + // `firstValueFrom(organizations$(nonActiveUserId)` once all the + // promise based methods are removed from `OrganizationService` and the + // main observable is refactored to accept a userId + const result = await organizationService.getAll(nonActiveUserId); + + expect(result[indexToUpdate]).not.toEqual( + new Organization(nonActiveUserMockOrganizations[indexToUpdate]), + ); + expect(result[indexToUpdate].id).toEqual( + new Organization(nonActiveUserMockOrganizations[indexToUpdate]).id, + ); + expectIsEqualExceptForIndex( + result, + nonActiveUserMockOrganizations.map((x) => new Organization(x)), + indexToUpdate, + ); + + // Just to be safe, lets make sure the active user didn't get updated + // at all + const activeUserState = await firstValueFrom(organizationService.organizations$); + expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x))); + expect(activeUserState).not.toEqual(result); }); }); - function organizationData(id: string, name: string) { - const data = new OrganizationData({} as any, {} as any); - data.id = id; - data.name = name; - data.identifier = "test"; + describe("replace()", () => { + it("replaces the entire organization list in state, defaulting to the active user", async () => { + const originalData = buildMockOrganizations(10); + fakeActiveUserState.nextState(arrayToRecord(originalData)); - return data; - } + const newData = buildMockOrganizations(10, "newData"); + await organizationService.replace(arrayToRecord(newData)); + + const result = await firstValueFrom(organizationService.organizations$); + + expect(result).toEqual(newData); + expect(result).not.toEqual(originalData); + }); + + // This is more or less a test for logouts + it("can replace state with null", async () => { + const originalData = buildMockOrganizations(2); + fakeActiveUserState.nextState(arrayToRecord(originalData)); + await organizationService.replace(null); + const result = await firstValueFrom(organizationService.organizations$); + expect(result).toEqual([]); + expect(result).not.toEqual(originalData); + }); + + it("can also replace state for a non-active user, if requested", async () => { + const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations"); + fakeActiveUserState.nextState(arrayToRecord(activeUserMockData)); + + const [nonActiveUserId, originalOrganizations] = await addNonActiveAccountToStateProvider(); + const newData = buildMockOrganizations(10, "newData"); + + await organizationService.replace(arrayToRecord(newData), nonActiveUserId); + // This can be updated to use + // `firstValueFrom(organizations$(nonActiveUserId)` once all the + // promise based methods are removed from `OrganizationService` and the + // main observable is refactored to accept a userId + const result = await organizationService.getAll(nonActiveUserId); + expect(result).toEqual(newData); + expect(result).not.toEqual(originalOrganizations); + + // Just to be safe, lets make sure the active user didn't get updated + // at all + const activeUserState = await firstValueFrom(organizationService.organizations$); + expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x))); + expect(activeUserState).not.toEqual(result); + }); + }); }); diff --git a/libs/common/src/admin-console/services/organization/organization.service.ts b/libs/common/src/admin-console/services/organization/organization.service.ts index 2c2c8b4e36a..3c651f4660e 100644 --- a/libs/common/src/admin-console/services/organization/organization.service.ts +++ b/libs/common/src/admin-console/services/organization/organization.service.ts @@ -1,111 +1,105 @@ -import { BehaviorSubject, concatMap, map, Observable } from "rxjs"; +import { map, Observable, firstValueFrom } from "rxjs"; import { Jsonify } from "type-fest"; -import { StateService } from "../../../platform/abstractions/state.service"; -import { KeyDefinition, ORGANIZATIONS_DISK, StateProvider } from "../../../platform/state"; -import { - InternalOrganizationServiceAbstraction, - isMember, -} from "../../abstractions/organization/organization.service.abstraction"; +import { ORGANIZATIONS_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; +import { InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction"; import { OrganizationData } from "../../models/data/organization.data"; import { Organization } from "../../models/domain/organization"; -export const ORGANIZATIONS = KeyDefinition.record( +/** + * The `KeyDefinition` for accessing organization lists in application state. + * @todo Ideally this wouldn't require a `fromJSON()` call, but `OrganizationData` + * has some properties that contain functions. This should probably get + * cleaned up. + */ +export const ORGANIZATIONS = UserKeyDefinition.record( ORGANIZATIONS_DISK, "organizations", { deserializer: (obj: Jsonify) => OrganizationData.fromJSON(obj), + clearOn: ["logout"], }, ); +/** + * Filter out organizations from an observable that __do not__ offer a + * families-for-enterprise sponsorship to members. + * @returns a function that can be used in `Observable` pipes, + * like `organizationService.organizations$` + */ +function mapToExcludeOrganizationsWithoutFamilySponsorshipSupport() { + return map((orgs) => orgs.filter((o) => o.canManageSponsorships)); +} + +/** + * Filter out organizations from an observable that the organization user + * __is not__ a direct member of. This will exclude organizations only + * accessible as a provider. + * @returns a function that can be used in `Observable` pipes, + * like `organizationService.organizations$` + */ +function mapToExcludeProviderOrganizations() { + return map((orgs) => orgs.filter((o) => o.isMember)); +} + +/** + * Map an observable stream of organizations down to a boolean indicating + * if any organizations exist (`orgs.length > 0`). + * @returns a function that can be used in `Observable` pipes, + * like `organizationService.organizations$` + */ +function mapToBooleanHasAnyOrganizations() { + return map((orgs) => orgs.length > 0); +} + +/** + * Map an observable stream of organizations down to a single organization. + * @param `organizationId` The ID of the organization you'd like to subscribe to + * @returns a function that can be used in `Observable` pipes, + * like `organizationService.organizations$` + */ +function mapToSingleOrganization(organizationId: string) { + return map((orgs) => orgs?.find((o) => o.id === organizationId)); +} + export class OrganizationService implements InternalOrganizationServiceAbstraction { - // marked for removal during AC-2009 - protected _organizations = new BehaviorSubject([]); - // marked for removal during AC-2009 - organizations$ = this._organizations.asObservable(); - // marked for removal during AC-2009 - memberOrganizations$ = this.organizations$.pipe(map((orgs) => orgs.filter(isMember))); + organizations$ = this.getOrganizationsFromState$(); + memberOrganizations$ = this.organizations$.pipe(mapToExcludeProviderOrganizations()); - activeUserOrganizations$: Observable; - activeUserMemberOrganizations$: Observable; - - constructor( - private stateService: StateService, - private stateProvider: StateProvider, - ) { - this.activeUserOrganizations$ = this.stateProvider - .getActive(ORGANIZATIONS) - .state$.pipe(map((data) => Object.values(data).map((o) => new Organization(o)))); - - this.activeUserMemberOrganizations$ = this.activeUserOrganizations$.pipe( - map((orgs) => orgs.filter(isMember)), - ); - - this.stateService.activeAccountUnlocked$ - .pipe( - concatMap(async (unlocked) => { - if (!unlocked) { - this._organizations.next([]); - return; - } - - const data = await this.stateService.getOrganizations(); - this.updateObservables(data); - }), - ) - .subscribe(); - } + constructor(private stateProvider: StateProvider) {} get$(id: string): Observable { - return this.organizations$.pipe(map((orgs) => orgs.find((o) => o.id === id))); + return this.organizations$.pipe(mapToSingleOrganization(id)); } async getAll(userId?: string): Promise { - const organizationsMap = await this.stateService.getOrganizations({ userId: userId }); - return Object.values(organizationsMap || {}).map((o) => new Organization(o)); + return await firstValueFrom(this.getOrganizationsFromState$(userId as UserId)); } async canManageSponsorships(): Promise { - const organizations = this._organizations.getValue(); - return organizations.some( - (o) => o.familySponsorshipAvailable || o.familySponsorshipFriendlyName !== null, + return await firstValueFrom( + this.organizations$.pipe( + mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(), + mapToBooleanHasAnyOrganizations(), + ), ); } - hasOrganizations(): boolean { - const organizations = this._organizations.getValue(); - return organizations.length > 0; + async hasOrganizations(): Promise { + return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations())); } - async upsert(organization: OrganizationData): Promise { - let organizations = await this.stateService.getOrganizations(); - if (organizations == null) { - organizations = {}; - } - - organizations[organization.id] = organization; - - await this.replace(organizations); + async upsert(organization: OrganizationData, userId?: UserId): Promise { + await this.stateFor(userId).update((existingOrganizations) => { + const organizations = existingOrganizations ?? {}; + organizations[organization.id] = organization; + return organizations; + }); } - async delete(id: string): Promise { - const organizations = await this.stateService.getOrganizations(); - if (organizations == null) { - return; - } - - if (organizations[id] == null) { - return; - } - - delete organizations[id]; - await this.replace(organizations); - } - - get(id: string): Organization { - const organizations = this._organizations.getValue(); - - return organizations.find((organization) => organization.id === id); + async get(id: string): Promise { + return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id))); } /** @@ -113,28 +107,46 @@ export class OrganizationService implements InternalOrganizationServiceAbstracti * @param id id of the organization */ async getFromState(id: string): Promise { - const organizationsMap = await this.stateService.getOrganizations(); - const organization = organizationsMap[id]; - if (organization == null) { - return null; - } - - return new Organization(organization); + return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id))); } - getByIdentifier(identifier: string): Organization { - const organizations = this._organizations.getValue(); - - return organizations.find((organization) => organization.identifier === identifier); + async replace(organizations: { [id: string]: OrganizationData }, userId?: UserId): Promise { + await this.stateFor(userId).update(() => organizations); } - async replace(organizations: { [id: string]: OrganizationData }) { - await this.stateService.setOrganizations(organizations); - this.updateObservables(organizations); + // Ideally this method would be renamed to organizations$() and the + // $organizations observable as it stands would be removed. This will + // require updates to callers, and so this method exists as a temporary + // workaround until we have time & a plan to update callers. + // + // It can be thought of as "organizations$ but with a userId option". + private getOrganizationsFromState$(userId?: UserId): Observable { + return this.stateFor(userId).state$.pipe(this.mapOrganizationRecordToArray()); } - private updateObservables(organizationsMap: { [id: string]: OrganizationData }) { - const organizations = Object.values(organizationsMap || {}).map((o) => new Organization(o)); - this._organizations.next(organizations); + /** + * Accepts a record of `OrganizationData`, which is how we store the + * organization list as a JSON object on disk, to an array of + * `Organization`, which is how the data is published to callers of the + * service. + * @returns a function that can be used to pipe organization data from + * stored state to an exposed object easily consumable by others. + */ + private mapOrganizationRecordToArray() { + return map, Organization[]>((orgs) => + Object.values(orgs ?? {})?.map((o) => new Organization(o)), + ); + } + + /** + * Fetches the organization list from on disk state for the specified user. + * @param userId the user ID to fetch the organization list for. Defaults to + * the currently active user. + * @returns an observable of organization state as it is stored on disk. + */ + private stateFor(userId?: UserId) { + return userId + ? this.stateProvider.getUser(userId, ORGANIZATIONS) + : this.stateProvider.getActive(ORGANIZATIONS); } } diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 12bac7de151..68b6260d48b 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -1,6 +1,5 @@ import { Observable } from "rxjs"; -import { OrganizationData } from "../../admin-console/models/data/organization.data"; import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; @@ -291,17 +290,6 @@ export abstract class StateService { setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise; getOrganizationInvitation: (options?: StorageOptions) => Promise; setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use OrganizationService - */ - getOrganizations: (options?: StorageOptions) => Promise<{ [id: string]: OrganizationData }>; - /** - * @deprecated Do not call this directly, use OrganizationService - */ - setOrganizations: ( - value: { [id: string]: OrganizationData }, - options?: StorageOptions, - ) => Promise; getPasswordGenerationOptions: (options?: StorageOptions) => Promise; setPasswordGenerationOptions: ( value: PasswordGeneratorOptions, diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index a647f64b5a5..64232ec6150 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -1,6 +1,5 @@ import { Jsonify } from "type-fest"; -import { OrganizationData } from "../../../admin-console/models/data/organization.data"; import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option"; @@ -91,7 +90,6 @@ export class AccountData { > = new EncryptionPair(); addEditCipherInfo?: AddEditCipherInfo; eventCollection?: EventData[]; - organizations?: { [id: string]: OrganizationData }; static fromJSON(obj: DeepJsonify): AccountData { if (obj == null) { diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index b8acbf9e4f7..73a56fda311 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -1,7 +1,6 @@ import { BehaviorSubject, Observable, map } from "rxjs"; import { Jsonify, JsonValue } from "type-fest"; -import { OrganizationData } from "../../admin-console/models/data/organization.data"; import { AccountService } from "../../auth/abstractions/account.service"; import { TokenService } from "../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; @@ -1474,32 +1473,6 @@ export class StateService< ); } - /** - * @deprecated Do not call this directly, use OrganizationService - */ - async getOrganizations(options?: StorageOptions): Promise<{ [id: string]: OrganizationData }> { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.data?.organizations; - } - - /** - * @deprecated Do not call this directly, use OrganizationService - */ - async setOrganizations( - value: { [id: string]: OrganizationData }, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.data.organizations = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getPasswordGenerationOptions(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index dd35cc8cd05..e0b17d0694c 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -35,6 +35,7 @@ import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state- import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token-svc-to-state-provider"; import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; +import { OrganizationMigrator } from "./migrations/40-move-organization-state-to-state-provider"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; @@ -43,7 +44,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 39; +export const CURRENT_VERSION = 40; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -84,7 +85,8 @@ export function createMigrationBuilder() { .with(VaultSettingsKeyMigrator, 35, 36) .with(AvatarColorMigrator, 36, 37) .with(TokenServiceStateProviderMigrator, 37, 38) - .with(MoveBillingAccountProfileMigrator, 38, CURRENT_VERSION); + .with(MoveBillingAccountProfileMigrator, 38, 39) + .with(OrganizationMigrator, 39, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts new file mode 100644 index 00000000000..94078e8153a --- /dev/null +++ b/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts @@ -0,0 +1,183 @@ +import { any, MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { OrganizationMigrator } from "./40-move-organization-state-to-state-provider"; + +const testDate = new Date(); +function exampleOrganization1() { + return JSON.stringify({ + id: "id", + name: "name", + status: 0, + type: 0, + enabled: false, + usePolicies: false, + useGroups: false, + useDirectory: false, + useEvents: false, + useTotp: false, + use2fa: false, + useApi: false, + useSso: false, + useKeyConnector: false, + useScim: false, + useCustomPermissions: false, + useResetPassword: false, + useSecretsManager: false, + usePasswordManager: false, + useActivateAutofillPolicy: false, + selfHost: false, + usersGetPremium: false, + seats: 0, + maxCollections: 0, + ssoBound: false, + identifier: "identifier", + resetPasswordEnrolled: false, + userId: "userId", + hasPublicAndPrivateKeys: false, + providerId: "providerId", + providerName: "providerName", + isProviderUser: false, + isMember: false, + familySponsorshipFriendlyName: "fsfn", + familySponsorshipAvailable: false, + planProductType: 0, + keyConnectorEnabled: false, + keyConnectorUrl: "kcu", + accessSecretsManager: false, + limitCollectionCreationDeletion: false, + allowAdminAccessToAllCollectionItems: false, + flexibleCollections: false, + familySponsorshipLastSyncDate: testDate, + }); +} + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + data: { + organizations: { + "organization-id-1": exampleOrganization1(), + "organization-id-2": { + // ... + }, + }, + otherStuff: "overStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + data: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + "user_user-1_organizations_organizations": { + "organization-id-1": exampleOrganization1(), + "organization-id-2": { + // ... + }, + }, + "user_user-2_organizations_organizations": null as any, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + data: { + otherStuff: "overStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + data: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("OrganizationMigrator", () => { + let helper: MockProxy; + let sut: OrganizationMigrator; + const keyDefinitionLike = { + key: "organizations", + stateDefinition: { + name: "organizations", + }, + }; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 40); + sut = new OrganizationMigrator(39, 40); + }); + + it("should remove organizations from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("user-1", { + data: { + otherStuff: "overStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should set organizations value for each account", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, { + "organization-id-1": exampleOrganization1(), + "organization-id-2": { + // ... + }, + }); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 40); + sut = new OrganizationMigrator(39, 40); + }); + + it.each(["user-1", "user-2"])("should null out new values", async (userId) => { + await sut.rollback(helper); + expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null); + }); + + it("should add explicit value back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("user-1", { + data: { + organizations: { + "organization-id-1": exampleOrganization1(), + "organization-id-2": { + // ... + }, + }, + otherStuff: "overStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should not try to restore values to missing accounts", async () => { + await sut.rollback(helper); + expect(helper.set).not.toHaveBeenCalledWith("user-3", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts b/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts new file mode 100644 index 00000000000..1dfb019942a --- /dev/null +++ b/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts @@ -0,0 +1,148 @@ +import { Jsonify } from "type-fest"; + +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +// Local declarations of `OrganizationData` and the types of it's properties. +// Duplicated to remain frozen in time when migration occurs. +enum OrganizationUserStatusType { + Invited = 0, + Accepted = 1, + Confirmed = 2, + Revoked = -1, +} + +enum OrganizationUserType { + Owner = 0, + Admin = 1, + User = 2, + Manager = 3, + Custom = 4, +} + +type PermissionsApi = { + accessEventLogs: boolean; + accessImportExport: boolean; + accessReports: boolean; + createNewCollections: boolean; + editAnyCollection: boolean; + deleteAnyCollection: boolean; + editAssignedCollections: boolean; + deleteAssignedCollections: boolean; + manageCiphers: boolean; + manageGroups: boolean; + manageSso: boolean; + managePolicies: boolean; + manageUsers: boolean; + manageResetPassword: boolean; + manageScim: boolean; +}; + +enum ProviderType { + Msp = 0, + Reseller = 1, +} + +enum ProductType { + Free = 0, + Families = 1, + Teams = 2, + Enterprise = 3, + TeamsStarter = 4, +} + +type OrganizationData = { + id: string; + name: string; + status: OrganizationUserStatusType; + type: OrganizationUserType; + enabled: boolean; + usePolicies: boolean; + useGroups: boolean; + useDirectory: boolean; + useEvents: boolean; + useTotp: boolean; + use2fa: boolean; + useApi: boolean; + useSso: boolean; + useKeyConnector: boolean; + useScim: boolean; + useCustomPermissions: boolean; + useResetPassword: boolean; + useSecretsManager: boolean; + usePasswordManager: boolean; + useActivateAutofillPolicy: boolean; + selfHost: boolean; + usersGetPremium: boolean; + seats: number; + maxCollections: number; + maxStorageGb?: number; + ssoBound: boolean; + identifier: string; + permissions: PermissionsApi; + resetPasswordEnrolled: boolean; + userId: string; + hasPublicAndPrivateKeys: boolean; + providerId: string; + providerName: string; + providerType?: ProviderType; + isProviderUser: boolean; + isMember: boolean; + familySponsorshipFriendlyName: string; + familySponsorshipAvailable: boolean; + planProductType: ProductType; + keyConnectorEnabled: boolean; + keyConnectorUrl: string; + familySponsorshipLastSyncDate?: Date; + familySponsorshipValidUntil?: Date; + familySponsorshipToDelete?: boolean; + accessSecretsManager: boolean; + limitCollectionCreationDeletion: boolean; + allowAdminAccessToAllCollectionItems: boolean; + flexibleCollections: boolean; +}; + +type ExpectedAccountType = { + data?: { + organizations?: Record>; + }; +}; + +const USER_ORGANIZATIONS: KeyDefinitionLike = { + key: "organizations", + stateDefinition: { + name: "organizations", + }, +}; + +export class OrganizationMigrator extends Migrator<39, 40> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + const value = account?.data?.organizations; + if (value != null) { + await helper.setToUser(userId, USER_ORGANIZATIONS, value); + delete account.data.organizations; + await helper.set(userId, account); + } + } + + await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account))); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + const value = await helper.getFromUser(userId, USER_ORGANIZATIONS); + if (account) { + account.data = Object.assign(account.data ?? {}, { + organizations: value, + }); + await helper.set(userId, account); + } + await helper.setToUser(userId, USER_ORGANIZATIONS, null); + } + + await Promise.all(accounts.map(({ userId, account }) => rollbackAccount(userId, account))); + } +} diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 796f02c7761..dfbe8d8c5e9 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -207,7 +207,7 @@ export class ImportComponent implements OnInit, OnDestroy { this.setImportOptions(); await this.initializeOrganizations(); - if (this.organizationId && this.canAccessImportExport(this.organizationId)) { + if (this.organizationId && (await this.canAccessImportExport(this.organizationId))) { this.handleOrganizationImportInit(); } else { this.handleImportInit(); @@ -359,7 +359,7 @@ export class ImportComponent implements OnInit, OnDestroy { importContents, this.organizationId, this.formGroup.controls.targetSelector.value, - this.canAccessImportExport(this.organizationId) && this._isFromAC, + (await this.canAccessImportExport(this.organizationId)) && this._isFromAC, ); //No errors, display success message @@ -379,11 +379,11 @@ export class ImportComponent implements OnInit, OnDestroy { } } - private canAccessImportExport(organizationId?: string): boolean { + private async canAccessImportExport(organizationId?: string): Promise { if (!organizationId) { return false; } - return this.organizationService.get(this.organizationId)?.canAccessImportExport; + return (await this.organizationService.get(this.organizationId))?.canAccessImportExport; } getFormatInstructionTitle() { From 2b92c7dd10451a1b0f7f07c5fef84d8d3ba17ad5 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Mon, 18 Mar 2024 10:17:07 -0700 Subject: [PATCH 29/41] Include BillingAccountProfileStateService dependency in storybook module (#8376) --- .../web/src/app/vault/components/premium-badge.stories.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/src/app/vault/components/premium-badge.stories.ts b/apps/web/src/app/vault/components/premium-badge.stories.ts index ffe11d73868..5433dd99813 100644 --- a/apps/web/src/app/vault/components/premium-badge.stories.ts +++ b/apps/web/src/app/vault/components/premium-badge.stories.ts @@ -1,6 +1,8 @@ import { Meta, moduleMetadata, Story } from "@storybook/angular"; +import { of } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { BadgeModule, I18nMockService } from "@bitwarden/components"; @@ -34,6 +36,12 @@ export default { return new MockMessagingService(); }, }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: of(false), + }, + }, ], }), ], From cc28149e60e86268fadc73bb90ee16d75838c3c8 Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Mon, 18 Mar 2024 14:36:43 -0400 Subject: [PATCH 30/41] [PM-5572] Event upload and collection state provider migration (#7863) * event upload and collection state provider migration * cipher can be null when exporting org * Addressing pr comments. Casting UserId from calling methods * fixing userAuth observable in event collection service * Adding more documentation for the changes. * cli needed state provider and account services added * Addressing pr comments on modifying should update * No need to auth on event upload * Simplifying the takeEvents for pulling user events * Reverting shouldUpdate to previous state * Removing redundant comment * Removing account service for event upload * Modifying the shouldUpdate to evaluate the logic outside of the observable * Adding back in the auth for event upload service and adding event upload to the cli logout method * Adding the browser service factories * Updating the browser services away from get background * Removing event collect and upload services from browser services * Removing the audit service import * Adding the event collection migration and migration test * Event collection state needs to be stored on disk * removing event collection from state service and abstraction * removing event collection from the account data * Saving the migrations themselves --- .../browser/src/background/main.background.ts | 8 +- .../event-collection-service.factory.ts | 10 +- .../event-upload-service.factory.ts | 10 +- .../src/popup/services/services.module.ts | 12 -- apps/cli/src/bw.ts | 7 +- apps/desktop/src/app/app.component.ts | 3 +- .../src/services/jslib-services.module.ts | 5 +- .../event/event-upload.service.ts | 4 +- libs/common/src/models/data/event.data.ts | 6 + .../platform/abstractions/state.service.ts | 3 - .../src/platform/models/domain/account.ts | 2 - .../src/platform/services/state.service.ts | 19 -- .../src/platform/state/state-definitions.ts | 1 + .../event/event-collection.service.ts | 102 ++++++++--- .../services/event/event-upload.service.ts | 53 ++++-- .../src/services/event/key-definitions.ts | 10 ++ libs/common/src/state-migrations/migrate.ts | 6 +- ...event-collection-to-state-provider.spec.ts | 168 ++++++++++++++++++ ...move-event-collection-to-state-provider.ts | 49 +++++ 19 files changed, 381 insertions(+), 97 deletions(-) create mode 100644 libs/common/src/services/event/key-definitions.ts create mode 100644 libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 4979ee6838e..b0f65ec8ce3 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -727,14 +727,16 @@ export default class MainBackground { ); this.eventUploadService = new EventUploadService( this.apiService, - this.stateService, + this.stateProvider, this.logService, + this.accountService, ); this.eventCollectionService = new EventCollectionService( this.cipherService, - this.stateService, + this.stateProvider, this.organizationService, this.eventUploadService, + this.accountService, ); this.totpService = new TotpService(this.cryptoFunctionService, this.logService); @@ -1108,7 +1110,7 @@ export default class MainBackground { async logout(expired: boolean, userId?: UserId) { userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.eventUploadService.uploadEvents(userId); + await this.eventUploadService.uploadEvents(userId as UserId); await Promise.all([ this.syncService.setLastSync(new Date(0), userId), diff --git a/apps/browser/src/background/service-factories/event-collection-service.factory.ts b/apps/browser/src/background/service-factories/event-collection-service.factory.ts index 7ce77da045b..ec892c73ddc 100644 --- a/apps/browser/src/background/service-factories/event-collection-service.factory.ts +++ b/apps/browser/src/background/service-factories/event-collection-service.factory.ts @@ -5,15 +5,14 @@ import { organizationServiceFactory, OrganizationServiceInitOptions, } from "../../admin-console/background/service-factories/organization-service.factory"; +import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory"; import { FactoryOptions, CachedServices, factory, } from "../../platform/background/service-factories/factory-options"; -import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../platform/background/service-factories/state-service.factory"; +import { stateProviderFactory } from "../../platform/background/service-factories/state-provider.factory"; +import { StateServiceInitOptions } from "../../platform/background/service-factories/state-service.factory"; import { cipherServiceFactory, CipherServiceInitOptions, @@ -43,9 +42,10 @@ export function eventCollectionServiceFactory( async () => new EventCollectionService( await cipherServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), await organizationServiceFactory(cache, opts), await eventUploadServiceFactory(cache, opts), + await accountServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/background/service-factories/event-upload-service.factory.ts b/apps/browser/src/background/service-factories/event-upload-service.factory.ts index fcaec459c0d..4e1d7949be1 100644 --- a/apps/browser/src/background/service-factories/event-upload-service.factory.ts +++ b/apps/browser/src/background/service-factories/event-upload-service.factory.ts @@ -1,6 +1,7 @@ import { EventUploadService as AbstractEventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; +import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory"; import { ApiServiceInitOptions, apiServiceFactory, @@ -14,10 +15,8 @@ import { logServiceFactory, LogServiceInitOptions, } from "../../platform/background/service-factories/log-service.factory"; -import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../platform/background/service-factories/state-service.factory"; +import { stateProviderFactory } from "../../platform/background/service-factories/state-provider.factory"; +import { StateServiceInitOptions } from "../../platform/background/service-factories/state-service.factory"; type EventUploadServiceOptions = FactoryOptions; @@ -37,8 +36,9 @@ export function eventUploadServiceFactory( async () => new EventUploadService( await apiServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), await logServiceFactory(cache, opts), + await accountServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7afd9df41e2..3fa03804ba4 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -17,8 +17,6 @@ import { LoginStrategyServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; @@ -263,16 +261,6 @@ function getBgService(service: keyof MainBackground) { useFactory: getBgService("devicesService"), deps: [], }, - { - provide: EventUploadService, - useFactory: getBgService("eventUploadService"), - deps: [], - }, - { - provide: EventCollectionService, - useFactory: getBgService("eventCollectionService"), - deps: [], - }, { provide: PlatformUtilsService, useExisting: ForegroundPlatformUtilsService, diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index e2110006e88..7435020af03 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -641,15 +641,17 @@ export class Main { this.eventUploadService = new EventUploadService( this.apiService, - this.stateService, + this.stateProvider, this.logService, + this.accountService, ); this.eventCollectionService = new EventCollectionService( this.cipherService, - this.stateService, + this.stateProvider, this.organizationService, this.eventUploadService, + this.accountService, ); } @@ -673,6 +675,7 @@ export class Main { }); const userId = await this.stateService.getUserId(); await Promise.all([ + this.eventUploadService.uploadEvents(userId as UserId), this.syncService.setLastSync(new Date(0)), this.cryptoService.clearKeys(), this.cipherService.clear(userId), diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 574dce93908..c674915b325 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -576,7 +576,8 @@ export class AppComponent implements OnInit, OnDestroy { let preLogoutActiveUserId; try { - await this.eventUploadService.uploadEvents(userBeingLoggedOut); + // Provide the userId of the user to upload events for + await this.eventUploadService.uploadEvents(userBeingLoggedOut as UserId); await this.syncService.setLastSync(new Date(0), userBeingLoggedOut); await this.cryptoService.clearKeys(userBeingLoggedOut); await this.cipherService.clear(userBeingLoggedOut); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index cc4af9b2071..d8dcf08835e 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -721,16 +721,17 @@ const typesafeProviders: Array = [ safeProvider({ provide: EventUploadServiceAbstraction, useClass: EventUploadService, - deps: [ApiServiceAbstraction, StateServiceAbstraction, LogService], + deps: [ApiServiceAbstraction, StateProvider, LogService, AccountServiceAbstraction], }), safeProvider({ provide: EventCollectionServiceAbstraction, useClass: EventCollectionService, deps: [ CipherServiceAbstraction, - StateServiceAbstraction, + StateProvider, OrganizationServiceAbstraction, EventUploadServiceAbstraction, + AccountServiceAbstraction, ], }), safeProvider({ diff --git a/libs/common/src/abstractions/event/event-upload.service.ts b/libs/common/src/abstractions/event/event-upload.service.ts index b68f6f718d4..5b7a98629a6 100644 --- a/libs/common/src/abstractions/event/event-upload.service.ts +++ b/libs/common/src/abstractions/event/event-upload.service.ts @@ -1,3 +1,5 @@ +import { UserId } from "../../types/guid"; + export abstract class EventUploadService { - uploadEvents: (userId?: string) => Promise; + uploadEvents: (userId?: UserId) => Promise; } diff --git a/libs/common/src/models/data/event.data.ts b/libs/common/src/models/data/event.data.ts index 2281f0258f7..e261e5fd3a1 100644 --- a/libs/common/src/models/data/event.data.ts +++ b/libs/common/src/models/data/event.data.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { EventType } from "../../enums"; export class EventData { @@ -5,4 +7,8 @@ export class EventData { cipherId: string; date: string; organizationId: string; + + static fromJSON(obj: Jsonify): EventData { + return Object.assign(new EventData(), obj); + } } diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 68b6260d48b..4c7e38f8e85 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -4,7 +4,6 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; -import { EventData } from "../../models/data/event.data"; import { WindowState } from "../../models/domain/window-state"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; @@ -253,8 +252,6 @@ export abstract class StateService { * @deprecated Do not call this directly, use SendService */ setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise; - getEventCollection: (options?: StorageOptions) => Promise; - setEventCollection: (value: EventData[], options?: StorageOptions) => Promise; getEverBeenUnlocked: (options?: StorageOptions) => Promise; setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise; getForceSetPasswordReason: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 64232ec6150..07efb505a5e 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -5,7 +5,6 @@ import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-pa import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option"; import { TrustedDeviceUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/trusted-device-user-decryption-option"; import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; -import { EventData } from "../../../models/data/event.data"; import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { GeneratorOptions } from "../../../tools/generator/generator-options"; import { @@ -89,7 +88,6 @@ export class AccountData { GeneratedPasswordHistory[] > = new EncryptionPair(); addEditCipherInfo?: AddEditCipherInfo; - eventCollection?: EventData[]; static fromJSON(obj: DeepJsonify): AccountData { if (obj == null) { diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 73a56fda311..6694b6ab3f7 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -8,7 +8,6 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; -import { EventData } from "../../models/data/event.data"; import { WindowState } from "../../models/domain/window-state"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; @@ -1176,24 +1175,6 @@ export class StateService< ); } - @withPrototypeForArrayMembers(EventData) - async getEventCollection(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.data?.eventCollection; - } - - async setEventCollection(value: EventData[], options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.data.eventCollection = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getEverBeenUnlocked(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 6a41b82dcc8..8115555b2ed 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -81,6 +81,7 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", { export const GENERATOR_DISK = new StateDefinition("generator", "disk"); export const GENERATOR_MEMORY = new StateDefinition("generator", "memory"); +export const EVENT_COLLECTION_DISK = new StateDefinition("eventCollection", "disk"); // Vault diff --git a/libs/common/src/services/event/event-collection.service.ts b/libs/common/src/services/event/event-collection.service.ts index f2447ba24eb..2d2b5530626 100644 --- a/libs/common/src/services/event/event-collection.service.ts +++ b/libs/common/src/services/event/event-collection.service.ts @@ -1,61 +1,105 @@ +import { firstValueFrom, map, from, zip } from "rxjs"; + import { EventCollectionService as EventCollectionServiceAbstraction } from "../../abstractions/event/event-collection.service"; import { EventUploadService } from "../../abstractions/event/event-upload.service"; import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { EventType } from "../../enums"; import { EventData } from "../../models/data/event.data"; -import { StateService } from "../../platform/abstractions/state.service"; +import { StateProvider } from "../../platform/state"; import { CipherService } from "../../vault/abstractions/cipher.service"; +import { EVENT_COLLECTION } from "./key-definitions"; + export class EventCollectionService implements EventCollectionServiceAbstraction { constructor( private cipherService: CipherService, - private stateService: StateService, + private stateProvider: StateProvider, private organizationService: OrganizationService, private eventUploadService: EventUploadService, + private accountService: AccountService, ) {} + /** Adds an event to the active user's event collection + * @param eventType the event type to be added + * @param cipherId if provided the id of the cipher involved in the event + * @param uploadImmediately in some cases the recorded events should be uploaded right after being added + * @param organizationId the organizationId involved in the event. If the cipherId is not provided an organizationId is required + */ async collect( eventType: EventType, cipherId: string = null, uploadImmediately = false, organizationId: string = null, ): Promise { - const authed = await this.stateService.getIsAuthenticated(); - if (!authed) { + const userId = await firstValueFrom(this.stateProvider.activeUserId$); + const eventStore = this.stateProvider.getUser(userId, EVENT_COLLECTION); + + if (!(await this.shouldUpdate(cipherId, organizationId))) { return; } - const organizations = await this.organizationService.getAll(); - if (organizations == null) { - return; - } - const orgIds = new Set(organizations.filter((o) => o.useEvents).map((o) => o.id)); - if (orgIds.size === 0) { - return; - } - if (cipherId != null) { - const cipher = await this.cipherService.get(cipherId); - if (cipher == null || cipher.organizationId == null || !orgIds.has(cipher.organizationId)) { - return; - } - } - if (organizationId != null) { - if (!orgIds.has(organizationId)) { - return; - } - } - let eventCollection = await this.stateService.getEventCollection(); - if (eventCollection == null) { - eventCollection = []; - } + const event = new EventData(); event.type = eventType; event.cipherId = cipherId; event.date = new Date().toISOString(); event.organizationId = organizationId; - eventCollection.push(event); - await this.stateService.setEventCollection(eventCollection); + + await eventStore.update((events) => { + events = events ?? []; + events.push(event); + return events; + }); + if (uploadImmediately) { await this.eventUploadService.uploadEvents(); } } + + /** Verifies if the event collection should be updated for the provided information + * @param cipherId the cipher for the event + * @param organizationId the organization for the event + */ + private async shouldUpdate( + cipherId: string = null, + organizationId: string = null, + ): Promise { + const orgIds$ = this.organizationService.organizations$.pipe( + map((orgs) => orgs?.filter((o) => o.useEvents)?.map((x) => x.id) ?? []), + ); + + const cipher$ = from(this.cipherService.get(cipherId)); + + const [accountInfo, orgIds, cipher] = await firstValueFrom( + zip(this.accountService.activeAccount$, orgIds$, cipher$), + ); + + // The user must be authorized + if (accountInfo.status != AuthenticationStatus.Unlocked) { + return false; + } + + // User must have organizations assigned to them + if (orgIds == null || orgIds.length == 0) { + return false; + } + + // If the cipher is null there must be an organization id provided + if (cipher == null && organizationId == null) { + return false; + } + + // If the cipher is present it must be in the user's org list + if (cipher != null && !orgIds.includes(cipher?.organizationId)) { + return false; + } + + // If the organization id is provided it must be in the user's org list + if (organizationId != null && !orgIds.includes(organizationId)) { + return false; + } + + return true; + } } diff --git a/libs/common/src/services/event/event-upload.service.ts b/libs/common/src/services/event/event-upload.service.ts index 75c7a7a1936..4ee4300c39c 100644 --- a/libs/common/src/services/event/event-upload.service.ts +++ b/libs/common/src/services/event/event-upload.service.ts @@ -1,15 +1,24 @@ +import { firstValueFrom, map } from "rxjs"; + import { ApiService } from "../../abstractions/api.service"; import { EventUploadService as EventUploadServiceAbstraction } from "../../abstractions/event/event-upload.service"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { EventData } from "../../models/data/event.data"; import { EventRequest } from "../../models/request/event.request"; import { LogService } from "../../platform/abstractions/log.service"; -import { StateService } from "../../platform/abstractions/state.service"; +import { StateProvider } from "../../platform/state"; +import { UserId } from "../../types/guid"; + +import { EVENT_COLLECTION } from "./key-definitions"; export class EventUploadService implements EventUploadServiceAbstraction { private inited = false; constructor( private apiService: ApiService, - private stateService: StateService, + private stateProvider: StateProvider, private logService: LogService, + private accountService: AccountService, ) {} init(checkOnInterval: boolean) { @@ -26,12 +35,26 @@ export class EventUploadService implements EventUploadServiceAbstraction { } } - async uploadEvents(userId?: string): Promise { - const authed = await this.stateService.getIsAuthenticated({ userId: userId }); - if (!authed) { + /** Upload the event collection from state. + * @param userId upload events for provided user. If not active user will be used. + */ + async uploadEvents(userId?: UserId): Promise { + if (!userId) { + userId = await firstValueFrom(this.stateProvider.activeUserId$); + } + + // Get the auth status from the provided user or the active user + const userAuth$ = this.accountService.accounts$.pipe( + map((accounts) => accounts[userId]?.status === AuthenticationStatus.Unlocked), + ); + + const isAuthenticated = await firstValueFrom(userAuth$); + if (!isAuthenticated) { return; } - const eventCollection = await this.stateService.getEventCollection({ userId: userId }); + + const eventCollection = await this.takeEvents(userId); + if (eventCollection == null || eventCollection.length === 0) { return; } @@ -45,15 +68,23 @@ export class EventUploadService implements EventUploadServiceAbstraction { }); try { await this.apiService.postEventsCollect(request); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.clearEvents(userId); } catch (e) { this.logService.error(e); + // Add the events back to state if there was an error and they were not uploaded. + await this.stateProvider.setUserState(EVENT_COLLECTION, eventCollection, userId); } } - private async clearEvents(userId?: string): Promise { - await this.stateService.setEventCollection(null, { userId: userId }); + /** Return user's events and then clear them from state + * @param userId the user to grab and clear events for + */ + private async takeEvents(userId: UserId): Promise { + let taken = null; + await this.stateProvider.getUser(userId, EVENT_COLLECTION).update((current) => { + taken = current ?? []; + return []; + }); + + return taken; } } diff --git a/libs/common/src/services/event/key-definitions.ts b/libs/common/src/services/event/key-definitions.ts new file mode 100644 index 00000000000..1059d24b724 --- /dev/null +++ b/libs/common/src/services/event/key-definitions.ts @@ -0,0 +1,10 @@ +import { EventData } from "../../models/data/event.data"; +import { KeyDefinition, EVENT_COLLECTION_DISK } from "../../platform/state"; + +export const EVENT_COLLECTION: KeyDefinition = KeyDefinition.array( + EVENT_COLLECTION_DISK, + "events", + { + deserializer: (s) => EventData.fromJSON(s), + }, +); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index e0b17d0694c..3bb947d0bd2 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -36,6 +36,7 @@ import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { OrganizationMigrator } from "./migrations/40-move-organization-state-to-state-provider"; +import { EventCollectionMigrator } from "./migrations/41-move-event-collection-to-state-provider"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; @@ -44,7 +45,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 40; +export const CURRENT_VERSION = 41; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -86,7 +87,8 @@ export function createMigrationBuilder() { .with(AvatarColorMigrator, 36, 37) .with(TokenServiceStateProviderMigrator, 37, 38) .with(MoveBillingAccountProfileMigrator, 38, 39) - .with(OrganizationMigrator, 39, CURRENT_VERSION); + .with(OrganizationMigrator, 39, 40) + .with(EventCollectionMigrator, 40, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts new file mode 100644 index 00000000000..d88100ebe2c --- /dev/null +++ b/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts @@ -0,0 +1,168 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { EventCollectionMigrator } from "./41-move-event-collection-to-state-provider"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + data: { + eventCollection: [ + { + type: 1107, + cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T21:59:50.169Z", + }, + { + type: 1107, + cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T22:02:06.089Z", + }, + ], + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + data: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + "user_user-1_eventCollection_eventCollection": [ + { + type: 1107, + cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T21:59:50.169Z", + }, + { + type: 1107, + cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T22:02:06.089Z", + }, + ], + "user_user-2_eventCollection_data": null as any, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + data: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + data: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("EventCollectionMigrator", () => { + let helper: MockProxy; + let sut: EventCollectionMigrator; + const keyDefinitionLike = { + stateDefinition: { + name: "eventCollection", + }, + key: "eventCollection", + }; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 40); + sut = new EventCollectionMigrator(40, 41); + }); + + it("should remove event collections from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("user-1", { + data: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should set event collections for each account", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, [ + { + type: 1107, + cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T21:59:50.169Z", + }, + { + type: 1107, + cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T22:02:06.089Z", + }, + ]); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 41); + sut = new EventCollectionMigrator(40, 41); + }); + + it.each(["user-1", "user-2"])("should null out new values", async (userId) => { + await sut.rollback(helper); + expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null); + }); + + it("should add event collection values back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalled(); + expect(helper.set).toHaveBeenCalledWith("user-1", { + data: { + eventCollection: [ + { + type: 1107, + cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T21:59:50.169Z", + }, + { + type: 1107, + cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T22:02:06.089Z", + }, + ], + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should not try to restore values to missing accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith("user-3", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts b/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts new file mode 100644 index 00000000000..f2f4d94d195 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts @@ -0,0 +1,49 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountState = { + data?: { + eventCollection?: []; + }; +}; + +const EVENT_COLLECTION: KeyDefinitionLike = { + stateDefinition: { + name: "eventCollection", + }, + key: "eventCollection", +}; + +export class EventCollectionMigrator extends Migrator<40, 41> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: ExpectedAccountState): Promise { + const value = account?.data?.eventCollection; + if (value != null) { + await helper.setToUser(userId, EVENT_COLLECTION, value); + delete account.data.eventCollection; + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise { + const value = await helper.getFromUser(userId, EVENT_COLLECTION); + if (account) { + account.data = Object.assign(account.data ?? {}, { + eventCollection: value, + }); + + await helper.set(userId, account); + } + await helper.setToUser(userId, EVENT_COLLECTION, null); + } + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} From a3f6b9eacb385655845b3decd597e224a243f994 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 18 Mar 2024 14:59:06 -0500 Subject: [PATCH 31/41] [PM-6853] Recreate `getUserStateOrDefault$` (#8374) * Recreate getUserStateOrDefault$ * Update Tests --- .../services/provider.service.spec.ts | 26 +++++++++---------- .../services/provider.service.ts | 16 +++++++++--- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/libs/common/src/admin-console/services/provider.service.spec.ts b/libs/common/src/admin-console/services/provider.service.spec.ts index 4fc02ee8936..fcba9d5023e 100644 --- a/libs/common/src/admin-console/services/provider.service.spec.ts +++ b/libs/common/src/admin-console/services/provider.service.spec.ts @@ -1,5 +1,5 @@ import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec"; -import { FakeActiveUserState } from "../../../spec/fake-state"; +import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state"; import { Utils } from "../../platform/misc/utils"; import { UserId } from "../../types/guid"; import { ProviderUserStatusType, ProviderUserType } from "../enums"; @@ -77,11 +77,13 @@ describe("ProviderService", () => { const fakeUserId = Utils.newGuid() as UserId; let fakeAccountService: FakeAccountService; let fakeStateProvider: FakeStateProvider; + let fakeUserState: FakeSingleUserState>; let fakeActiveUserState: FakeActiveUserState>; beforeEach(async () => { fakeAccountService = mockAccountServiceWith(fakeUserId); fakeStateProvider = new FakeStateProvider(fakeAccountService); + fakeUserState = fakeStateProvider.singleUser.getFake(fakeUserId, PROVIDERS); fakeActiveUserState = fakeStateProvider.activeUser.getFake(PROVIDERS); providerService = new ProviderService(fakeStateProvider); }); @@ -89,7 +91,7 @@ describe("ProviderService", () => { describe("getAll()", () => { it("Returns an array of all providers stored in state", async () => { const mockData: ProviderData[] = buildMockProviders(5); - fakeActiveUserState.nextState(arrayToRecord(mockData)); + fakeUserState.nextState(arrayToRecord(mockData)); const providers = await providerService.getAll(); expect(providers).toHaveLength(5); expect(providers).toEqual(mockData.map((x) => new Provider(x))); @@ -97,7 +99,7 @@ describe("ProviderService", () => { it("Returns an empty array if no providers are found in state", async () => { const mockData: ProviderData[] = undefined; - fakeActiveUserState.nextState(arrayToRecord(mockData)); + fakeUserState.nextState(arrayToRecord(mockData)); const result = await providerService.getAll(); expect(result).toEqual([]); }); @@ -106,7 +108,7 @@ describe("ProviderService", () => { describe("get()", () => { it("Returns a single provider from state that matches the specified id", async () => { const mockData = buildMockProviders(5); - fakeActiveUserState.nextState(arrayToRecord(mockData)); + fakeUserState.nextState(arrayToRecord(mockData)); const result = await providerService.get(mockData[3].id); expect(result).toEqual(new Provider(mockData[3])); }); @@ -120,15 +122,12 @@ describe("ProviderService", () => { describe("save()", () => { it("replaces the entire provider list in state for the active user", async () => { const originalData = buildMockProviders(10); - fakeActiveUserState.nextState(arrayToRecord(originalData)); + fakeUserState.nextState(arrayToRecord(originalData)); - const newData = buildMockProviders(10, "newData"); - await providerService.save(arrayToRecord(newData)); + const newData = arrayToRecord(buildMockProviders(10, "newData")); + await providerService.save(newData); - const result = await providerService.getAll(); - - expect(result).toEqual(newData); - expect(result).not.toEqual(originalData); + expect(fakeActiveUserState.nextMock).toHaveBeenCalledWith([fakeUserId, newData]); }); // This is more or less a test for logouts @@ -136,9 +135,8 @@ describe("ProviderService", () => { const originalData = buildMockProviders(2); fakeActiveUserState.nextState(arrayToRecord(originalData)); await providerService.save(null); - const result = await providerService.getAll(); - expect(result).toEqual([]); - expect(result).not.toEqual(originalData); + + expect(fakeActiveUserState.nextMock).toHaveBeenCalledWith([fakeUserId, null]); }); }); }); diff --git a/libs/common/src/admin-console/services/provider.service.ts b/libs/common/src/admin-console/services/provider.service.ts index e68ea5bf9d3..47291a5520e 100644 --- a/libs/common/src/admin-console/services/provider.service.ts +++ b/libs/common/src/admin-console/services/provider.service.ts @@ -1,4 +1,4 @@ -import { Observable, map, firstValueFrom } from "rxjs"; +import { Observable, map, firstValueFrom, of, switchMap, take } from "rxjs"; import { KeyDefinition, PROVIDERS_DISK, StateProvider } from "../../platform/state"; import { UserId } from "../../types/guid"; @@ -18,9 +18,17 @@ export class ProviderService implements ProviderServiceAbstraction { constructor(private stateProvider: StateProvider) {} private providers$(userId?: UserId): Observable { - return this.stateProvider - .getUserState$(PROVIDERS, userId) - .pipe(this.mapProviderRecordToArray()); + // FIXME: Can be replaced with `getUserStateOrDefault$` if we weren't trying to pick this. + return ( + userId != null + ? this.stateProvider.getUser(userId, PROVIDERS).state$ + : this.stateProvider.activeUserId$.pipe( + take(1), + switchMap((userId) => + userId != null ? this.stateProvider.getUser(userId, PROVIDERS).state$ : of(null), + ), + ) + ).pipe(this.mapProviderRecordToArray()); } private mapProviderRecordToArray() { From b95dfd9d3090a3df02feca5536b669c26380abdf Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 19 Mar 2024 12:19:32 +1000 Subject: [PATCH 32/41] [AC-2276] Move policyService helper methods to domain object (#8254) * Move mapPolicyFromResponse and mapPoliciesFromToken to static factory methods --- .../organizations/members/people.component.ts | 3 +- .../app/auth/accept-organization.component.ts | 2 +- .../web/src/app/auth/login/login.component.ts | 2 +- .../trial-initiation.component.ts | 4 +- .../policy/policy.service.abstraction.ts | 14 ----- .../src/admin-console/models/domain/policy.ts | 10 +++ .../services/policy/policy-api.service.ts | 5 +- .../services/policy/policy.service.spec.ts | 62 ------------------- .../services/policy/policy.service.ts | 15 ----- 9 files changed, 17 insertions(+), 100 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/members/people.component.ts b/apps/web/src/app/admin-console/organizations/members/people.component.ts index 4e45f70b6dd..b3142125dfe 100644 --- a/apps/web/src/app/admin-console/organizations/members/people.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/people.component.ts @@ -35,6 +35,7 @@ import { PolicyType, } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { ProductType } from "@bitwarden/common/enums"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; @@ -155,7 +156,7 @@ export class PeopleComponent switchMap((organization) => { if (organization.isProviderUser) { return from(this.policyApiService.getPolicies(organization.id)).pipe( - map((response) => this.policyService.mapPoliciesFromToken(response)), + map((response) => Policy.fromListResponse(response)), ); } diff --git a/apps/web/src/app/auth/accept-organization.component.ts b/apps/web/src/app/auth/accept-organization.component.ts index 21f3a41fa6c..52e3b644940 100644 --- a/apps/web/src/app/auth/accept-organization.component.ts +++ b/apps/web/src/app/auth/accept-organization.component.ts @@ -167,7 +167,7 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { qParams.email, qParams.organizationUserId, ); - policyList = this.policyService.mapPoliciesFromToken(policies); + policyList = Policy.fromListResponse(policies); } catch (e) { this.logService.error(e); } diff --git a/apps/web/src/app/auth/login/login.component.ts b/apps/web/src/app/auth/login/login.component.ts index 9075c3ab28c..1d2d1859e94 100644 --- a/apps/web/src/app/auth/login/login.component.ts +++ b/apps/web/src/app/auth/login/login.component.ts @@ -120,7 +120,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { invite.email, invite.organizationUserId, ); - policyList = this.policyService.mapPoliciesFromToken(this.policies); + policyList = Policy.fromListResponse(this.policies); } catch (e) { this.logService.error(e); } diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts index df4962185da..52a4120b1a0 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts @@ -7,7 +7,6 @@ import { Subject, takeUntil } from "rxjs"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PlanType } from "@bitwarden/common/billing/enums"; @@ -191,8 +190,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { invite.organizationUserId, ); if (policies.data != null) { - const policiesData = policies.data.map((p) => new PolicyData(p)); - this.policies = policiesData.map((p) => new Policy(p)); + this.policies = Policy.fromListResponse(policies); } } catch (e) { this.logService.error(e); diff --git a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts index 1a85fd79fce..fb805f94cd9 100644 --- a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts @@ -1,13 +1,11 @@ import { Observable } from "rxjs"; -import { ListResponse } from "../../../models/response/list.response"; import { UserId } from "../../../types/guid"; import { PolicyType } from "../../enums"; import { PolicyData } from "../../models/data/policy.data"; import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; import { Policy } from "../../models/domain/policy"; import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options"; -import { PolicyResponse } from "../../models/response/policy.response"; export abstract class PolicyService { /** @@ -75,18 +73,6 @@ export abstract class PolicyService { policies: Policy[], orgId: string, ) => [ResetPasswordPolicyOptions, boolean]; - - // Helpers - - /** - * Instantiates {@link Policy} objects from {@link PolicyResponse} objects. - */ - mapPolicyFromResponse: (policyResponse: PolicyResponse) => Policy; - - /** - * Instantiates {@link Policy} objects from {@link ListResponse} objects. - */ - mapPoliciesFromToken: (policiesResponse: ListResponse) => Policy[]; } export abstract class InternalPolicyService extends PolicyService { diff --git a/libs/common/src/admin-console/models/domain/policy.ts b/libs/common/src/admin-console/models/domain/policy.ts index 65af09b5888..c2f9c9c8dfc 100644 --- a/libs/common/src/admin-console/models/domain/policy.ts +++ b/libs/common/src/admin-console/models/domain/policy.ts @@ -1,7 +1,9 @@ +import { ListResponse } from "../../../models/response/list.response"; import Domain from "../../../platform/models/domain/domain-base"; import { PolicyId } from "../../../types/guid"; import { PolicyType } from "../../enums"; import { PolicyData } from "../data/policy.data"; +import { PolicyResponse } from "../response/policy.response"; export class Policy extends Domain { id: PolicyId; @@ -27,4 +29,12 @@ export class Policy extends Domain { this.data = obj.data; this.enabled = obj.enabled; } + + static fromResponse(response: PolicyResponse): Policy { + return new Policy(new PolicyData(response)); + } + + static fromListResponse(response: ListResponse): Policy[] | undefined { + return response.data?.map((d) => Policy.fromResponse(d)) ?? undefined; + } } diff --git a/libs/common/src/admin-console/services/policy/policy-api.service.ts b/libs/common/src/admin-console/services/policy/policy-api.service.ts index 0eb37f23051..c7f093286e1 100644 --- a/libs/common/src/admin-console/services/policy/policy-api.service.ts +++ b/libs/common/src/admin-console/services/policy/policy-api.service.ts @@ -10,6 +10,7 @@ import { InternalPolicyService } from "../../abstractions/policy/policy.service. import { PolicyType } from "../../enums"; import { PolicyData } from "../../models/data/policy.data"; import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; +import { Policy } from "../../models/domain/policy"; import { PolicyRequest } from "../../models/request/policy.request"; import { PolicyResponse } from "../../models/response/policy.response"; @@ -86,9 +87,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction { const masterPasswordPolicyResponse = await this.getMasterPasswordPolicyResponseForOrgUser(orgId); - const masterPasswordPolicy = this.policyService.mapPolicyFromResponse( - masterPasswordPolicyResponse, - ); + const masterPasswordPolicy = Policy.fromResponse(masterPasswordPolicyResponse); if (!masterPasswordPolicy) { return null; diff --git a/libs/common/src/admin-console/services/policy/policy.service.spec.ts b/libs/common/src/admin-console/services/policy/policy.service.spec.ts index 672a53d34e5..8fa79f4d1c4 100644 --- a/libs/common/src/admin-console/services/policy/policy.service.spec.ts +++ b/libs/common/src/admin-console/services/policy/policy.service.spec.ts @@ -16,9 +16,7 @@ import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domai import { Organization } from "../../../admin-console/models/domain/organization"; import { Policy } from "../../../admin-console/models/domain/policy"; import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options"; -import { PolicyResponse } from "../../../admin-console/models/response/policy.response"; import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service"; -import { ListResponse } from "../../../models/response/list.response"; import { PolicyId, UserId } from "../../../types/guid"; describe("PolicyService", () => { @@ -265,66 +263,6 @@ describe("PolicyService", () => { }); }); - describe("mapPoliciesFromToken", () => { - it("null", async () => { - const result = policyService.mapPoliciesFromToken(null); - - expect(result).toEqual(null); - }); - - it("null data", async () => { - const model = new ListResponse(null, PolicyResponse); - model.data = null; - const result = policyService.mapPoliciesFromToken(model); - - expect(result).toEqual(null); - }); - - it("empty array", async () => { - const model = new ListResponse(null, PolicyResponse); - const result = policyService.mapPoliciesFromToken(model); - - expect(result).toEqual([]); - }); - - it("success", async () => { - const policyResponse: any = { - Data: [ - { - Id: "1", - OrganizationId: "organization-1", - Type: PolicyType.DisablePersonalVaultExport, - Enabled: true, - Data: { requireUpper: true }, - }, - { - Id: "2", - OrganizationId: "organization-2", - Type: PolicyType.DisableSend, - Enabled: false, - Data: { minComplexity: 5, minLength: 20 }, - }, - ], - }; - const model = new ListResponse(policyResponse, PolicyResponse); - const result = policyService.mapPoliciesFromToken(model); - - expect(result).toEqual([ - new Policy( - policyData("1", "organization-1", PolicyType.DisablePersonalVaultExport, true, { - requireUpper: true, - }), - ), - new Policy( - policyData("2", "organization-2", PolicyType.DisableSend, false, { - minComplexity: 5, - minLength: 20, - }), - ), - ]); - }); - }); - describe("get$", () => { it("returns the specified PolicyType", async () => { activeUserState.nextState( diff --git a/libs/common/src/admin-console/services/policy/policy.service.ts b/libs/common/src/admin-console/services/policy/policy.service.ts index 4672d9b810e..d60d2e32938 100644 --- a/libs/common/src/admin-console/services/policy/policy.service.ts +++ b/libs/common/src/admin-console/services/policy/policy.service.ts @@ -1,6 +1,5 @@ import { combineLatest, firstValueFrom, map, Observable, of } from "rxjs"; -import { ListResponse } from "../../../models/response/list.response"; import { KeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state"; import { PolicyId, UserId } from "../../../types/guid"; import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction"; @@ -11,7 +10,6 @@ import { MasterPasswordPolicyOptions } from "../../models/domain/master-password import { Organization } from "../../models/domain/organization"; import { Policy } from "../../models/domain/policy"; import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options"; -import { PolicyResponse } from "../../models/response/policy.response"; const policyRecordToArray = (policiesMap: { [id: string]: PolicyData }) => Object.values(policiesMap || {}).map((f) => new Policy(f)); @@ -212,19 +210,6 @@ export class PolicyService implements InternalPolicyServiceAbstraction { return [resetPasswordPolicyOptions, policy?.enabled ?? false]; } - mapPolicyFromResponse(policyResponse: PolicyResponse): Policy { - const policyData = new PolicyData(policyResponse); - return new Policy(policyData); - } - - mapPoliciesFromToken(policiesResponse: ListResponse): Policy[] { - if (policiesResponse?.data == null) { - return null; - } - - return policiesResponse.data.map((response) => this.mapPolicyFromResponse(response)); - } - async upsert(policy: PolicyData): Promise { await this.activeUserPolicyState.update((policies) => { policies ??= {}; From 13e1672c6944934c3aacc705c2eb25ca42ca6f7f Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Tue, 19 Mar 2024 06:14:49 -0400 Subject: [PATCH 33/41] [PM-6658] Migrate disableFavicon to Domain Settings service and remove Settings service (#8333) * add showFavicons to domain settings * replace usages of disableFavicon with showFavicons via the domain settings service and remove/replace settings service * create migration for disableFavicon * cleanup --- .../background/overlay.background.spec.ts | 25 +++- .../autofill/background/overlay.background.ts | 16 +-- .../browser/src/background/main.background.ts | 6 +- .../settings-service.factory.ts | 28 ----- .../src/popup/services/services.module.ts | 14 +-- .../src/popup/settings/options.component.ts | 6 +- .../src/services/browser-settings.service.ts | 11 -- apps/cli/src/bw.ts | 3 - .../src/app/accounts/settings.component.ts | 10 +- apps/desktop/src/app/app.component.ts | 2 - apps/web/src/app/app.component.ts | 4 - .../src/app/settings/preferences.component.ts | 10 +- .../vault-items/vault-items.stories.ts | 16 +-- .../src/services/jslib-services.module.ts | 7 -- .../src/vault/components/icon.component.ts | 8 +- .../src/abstractions/settings.service.ts | 8 -- .../services/domain-settings.service.ts | 16 +++ .../platform/abstractions/state.service.ts | 8 -- .../platform/models/domain/global-state.ts | 1 - .../src/platform/services/state.service.ts | 21 ---- libs/common/src/services/settings.service.ts | 40 ------- libs/common/src/state-migrations/migrate.ts | 6 +- ...-to-domain-settings-state-provider.spec.ts | 108 ++++++++++++++++++ ...vicon-to-domain-settings-state-provider.ts | 45 ++++++++ .../src/vault/icon/build-cipher-icon.ts | 17 +-- 25 files changed, 237 insertions(+), 199 deletions(-) delete mode 100644 apps/browser/src/background/service-factories/settings-service.factory.ts delete mode 100644 apps/browser/src/services/browser-settings.service.ts delete mode 100644 libs/common/src/abstractions/settings.service.ts delete mode 100644 libs/common/src/services/settings.service.ts create mode 100644 libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index f0da1af11d4..ebad626d8df 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -8,11 +8,21 @@ import { AutofillOverlayVisibility, } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { + DefaultDomainSettingsService, + DomainSettingsService, +} from "@bitwarden/common/autofill/services/domain-settings.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; import { I18nService } from "@bitwarden/common/platform/services/i18n.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; -import { SettingsService } from "@bitwarden/common/services/settings.service"; +import { + FakeStateProvider, + FakeAccountService, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -41,6 +51,10 @@ import OverlayBackground from "./overlay.background"; const iconServerUrl = "https://icons.bitwarden.com/"; describe("OverlayBackground", () => { + const mockUserId = Utils.newGuid() as UserId; + const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); + const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); + let domainSettingsService: DomainSettingsService; let buttonPortSpy: chrome.runtime.Port; let listPortSpy: chrome.runtime.Port; let overlayBackground: OverlayBackground; @@ -50,7 +64,6 @@ describe("OverlayBackground", () => { const environmentService = mock({ getIconsUrl: () => iconServerUrl, }); - const settingsService = mock(); const stateService = mock(); const autofillSettingsService = mock(); const i18nService = mock(); @@ -72,12 +85,13 @@ describe("OverlayBackground", () => { }; beforeEach(() => { + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); overlayBackground = new OverlayBackground( cipherService, autofillService, authService, environmentService, - settingsService, + domainSettingsService, stateService, autofillSettingsService, i18nService, @@ -90,6 +104,7 @@ describe("OverlayBackground", () => { .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); themeStateService.selectedTheme$ = of(ThemeType.Light); + domainSettingsService.showFavicons$ = of(true); void overlayBackground.init(); }); @@ -274,7 +289,7 @@ describe("OverlayBackground", () => { card: { subTitle: "Mastercard, *1234" }, }); - it("formats and returns the cipher data", () => { + it("formats and returns the cipher data", async () => { overlayBackground["overlayLoginCiphers"] = new Map([ ["overlay-cipher-0", cipher2], ["overlay-cipher-1", cipher1], @@ -282,7 +297,7 @@ describe("OverlayBackground", () => { ["overlay-cipher-3", cipher4], ]); - const overlayCipherData = overlayBackground["getOverlayCipherData"](); + const overlayCipherData = await overlayBackground["getOverlayCipherData"](); expect(overlayCipherData).toStrictEqual([ { diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 00be50c3284..6c3aea9dc71 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1,10 +1,10 @@ import { firstValueFrom } from "rxjs"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -92,7 +92,7 @@ class OverlayBackground implements OverlayBackgroundInterface { private autofillService: AutofillService, private authService: AuthService, private environmentService: EnvironmentService, - private settingsService: SettingsService, + private domainSettingsService: DomainSettingsService, private stateService: StateService, private autofillSettingsService: AutofillSettingsServiceAbstraction, private i18nService: I18nService, @@ -145,7 +145,7 @@ class OverlayBackground implements OverlayBackgroundInterface { this.overlayLoginCiphers.set(`overlay-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); } - const ciphers = this.getOverlayCipherData(); + const ciphers = await this.getOverlayCipherData(); this.overlayListPort?.postMessage({ command: "updateOverlayListCiphers", ciphers }); await BrowserApi.tabSendMessageData(currentTab, "updateIsOverlayCiphersPopulated", { isOverlayCiphersPopulated: Boolean(ciphers.length), @@ -156,8 +156,8 @@ class OverlayBackground implements OverlayBackgroundInterface { * Strips out unnecessary data from the ciphers and returns an array of * objects that contain the cipher data needed for the overlay list. */ - private getOverlayCipherData(): OverlayCipherData[] { - const isFaviconDisabled = this.settingsService.getDisableFavicon(); + private async getOverlayCipherData(): Promise { + const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); const overlayCiphersArray = Array.from(this.overlayLoginCiphers); const overlayCipherData = []; let loginCipherIcon: WebsiteIconData; @@ -165,7 +165,7 @@ class OverlayBackground implements OverlayBackgroundInterface { for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) { const [overlayCipherId, cipher] = overlayCiphersArray[cipherIndex]; if (!loginCipherIcon && cipher.type === CipherType.Login) { - loginCipherIcon = buildCipherIcon(this.iconsServerUrl, cipher, isFaviconDisabled); + loginCipherIcon = buildCipherIcon(this.iconsServerUrl, cipher, showFavicons); } overlayCipherData.push({ @@ -177,7 +177,7 @@ class OverlayBackground implements OverlayBackgroundInterface { icon: cipher.type === CipherType.Login ? loginCipherIcon - : buildCipherIcon(this.iconsServerUrl, cipher, isFaviconDisabled), + : buildCipherIcon(this.iconsServerUrl, cipher, showFavicons), login: cipher.type === CipherType.Login ? { username: cipher.login.username } : null, card: cipher.type === CipherType.Card ? cipher.card.subTitle : null, }); @@ -699,7 +699,7 @@ class OverlayBackground implements OverlayBackgroundInterface { styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`), theme: await firstValueFrom(this.themeStateService.selectedTheme$), translations: this.getTranslations(), - ciphers: isOverlayListPort ? this.getOverlayCipherData() : null, + ciphers: isOverlayListPort ? await this.getOverlayCipherData() : null, }); this.updateOverlayPosition({ overlayElement: isOverlayListPort diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index b0f65ec8ce3..b3b9be3dcd4 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -14,7 +14,6 @@ import { EventCollectionService as EventCollectionServiceAbstraction } from "@bi import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; -import { SettingsService as SettingsServiceAbstraction } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; @@ -213,7 +212,6 @@ import { BrowserPlatformUtilsService } from "../platform/services/platform-utils import { BackgroundDerivedStateProvider } from "../platform/state/background-derived-state.provider"; import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service"; import { BrowserSendService } from "../services/browser-send.service"; -import { BrowserSettingsService } from "../services/browser-settings.service"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; import FilelessImporterBackground from "../tools/background/fileless-importer.background"; import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service"; @@ -242,7 +240,6 @@ export default class MainBackground { appIdService: AppIdServiceAbstraction; apiService: ApiServiceAbstraction; environmentService: BrowserEnvironmentService; - settingsService: SettingsServiceAbstraction; cipherService: CipherServiceAbstraction; folderService: InternalFolderServiceAbstraction; collectionService: CollectionServiceAbstraction; @@ -488,7 +485,6 @@ export default class MainBackground { (expired: boolean) => this.logout(expired), ); this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); - this.settingsService = new BrowserSettingsService(this.stateService); this.fileUploadService = new FileUploadService(this.logService); this.cipherFileUploadService = new CipherFileUploadService( this.apiService, @@ -890,7 +886,7 @@ export default class MainBackground { this.autofillService, this.authService, this.environmentService, - this.settingsService, + this.domainSettingsService, this.stateService, this.autofillSettingsService, this.i18nService, diff --git a/apps/browser/src/background/service-factories/settings-service.factory.ts b/apps/browser/src/background/service-factories/settings-service.factory.ts deleted file mode 100644 index 28e97de51fa..00000000000 --- a/apps/browser/src/background/service-factories/settings-service.factory.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { SettingsService as AbstractSettingsService } from "@bitwarden/common/abstractions/settings.service"; - -import { - FactoryOptions, - CachedServices, - factory, -} from "../../platform/background/service-factories/factory-options"; -import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../platform/background/service-factories/state-service.factory"; -import { BrowserSettingsService } from "../../services/browser-settings.service"; - -type SettingsServiceFactoryOptions = FactoryOptions; - -export type SettingsServiceInitOptions = SettingsServiceFactoryOptions & StateServiceInitOptions; - -export function settingsServiceFactory( - cache: { settingsService?: AbstractSettingsService } & CachedServices, - opts: SettingsServiceInitOptions, -): Promise { - return factory( - cache, - "settingsService", - opts, - async () => new BrowserSettingsService(await stateServiceFactory(cache, opts)), - ); -} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 3fa03804ba4..52de0303fae 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -19,7 +19,6 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -40,6 +39,10 @@ import { AutofillSettingsService, AutofillSettingsServiceAbstraction, } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { + DefaultDomainSettingsService, + DomainSettingsService, +} from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsService, UserNotificationSettingsServiceAbstraction, @@ -115,7 +118,6 @@ import { ForegroundPlatformUtilsService } from "../../platform/services/platform import { ForegroundDerivedStateProvider } from "../../platform/state/foreground-derived-state.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; import { BrowserSendService } from "../../services/browser-send.service"; -import { BrowserSettingsService } from "../../services/browser-settings.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; import { VaultFilterService } from "../../vault/services/vault-filter.service"; @@ -334,11 +336,9 @@ function getBgService(service: keyof MainBackground) { }, { provide: SyncService, useFactory: getBgService("syncService"), deps: [] }, { - provide: SettingsService, - useFactory: (stateService: StateServiceAbstraction) => { - return new BrowserSettingsService(stateService); - }, - deps: [StateServiceAbstraction], + provide: DomainSettingsService, + useClass: DefaultDomainSettingsService, + deps: [StateProvider], }, { provide: AbstractStorageService, diff --git a/apps/browser/src/popup/settings/options.component.ts b/apps/browser/src/popup/settings/options.component.ts index 2142693e2a4..dbdb94c5860 100644 --- a/apps/browser/src/popup/settings/options.component.ts +++ b/apps/browser/src/popup/settings/options.component.ts @@ -1,7 +1,6 @@ import { Component, OnInit } from "@angular/core"; import { firstValueFrom } from "rxjs"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; @@ -56,7 +55,6 @@ export class OptionsComponent implements OnInit { private badgeSettingsService: BadgeSettingsServiceAbstraction, i18nService: I18nService, private themeStateService: ThemeStateService, - private settingsService: SettingsService, private vaultSettingsService: VaultSettingsService, ) { this.themeOptions = [ @@ -119,7 +117,7 @@ export class OptionsComponent implements OnInit { this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$); - this.enableFavicon = !this.settingsService.getDisableFavicon(); + this.enableFavicon = await firstValueFrom(this.domainSettingsService.showFavicons$); this.enableBadgeCounter = await firstValueFrom(this.badgeSettingsService.enableBadgeCounter$); @@ -169,7 +167,7 @@ export class OptionsComponent implements OnInit { } async updateFavicon() { - await this.settingsService.setDisableFavicon(!this.enableFavicon); + await this.domainSettingsService.setShowFavicons(this.enableFavicon); } async updateBadgeCounter() { diff --git a/apps/browser/src/services/browser-settings.service.ts b/apps/browser/src/services/browser-settings.service.ts deleted file mode 100644 index 50c27ce4f69..00000000000 --- a/apps/browser/src/services/browser-settings.service.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BehaviorSubject } from "rxjs"; - -import { SettingsService } from "@bitwarden/common/services/settings.service"; - -import { browserSession, sessionSync } from "../platform/decorators/session-sync-observable"; - -@browserSession -export class BrowserSettingsService extends SettingsService { - @sessionSync({ initializer: (b: boolean) => b }) - protected _disableFavicon: BehaviorSubject; -} diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 7435020af03..95e306bfc0e 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -90,7 +90,6 @@ import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { SearchService } from "@bitwarden/common/services/search.service"; -import { SettingsService } from "@bitwarden/common/services/settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; import { @@ -159,7 +158,6 @@ export class Main { appIdService: AppIdService; apiService: NodeApiService; environmentService: EnvironmentService; - settingsService: SettingsService; cipherService: CipherService; folderService: InternalFolderService; organizationUserService: OrganizationUserService; @@ -375,7 +373,6 @@ export class Main { this.containerService = new ContainerService(this.cryptoService, this.encryptService); - this.settingsService = new SettingsService(this.stateService); this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); this.fileUploadService = new FileUploadService(this.logService); diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 242e1533e96..e066d4ec2e2 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -3,13 +3,12 @@ import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, firstValueFrom, Observable, Subject } from "rxjs"; import { concatMap, debounceTime, filter, map, switchMap, takeUntil, tap } from "rxjs/operators"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DeviceType } from "@bitwarden/common/enums"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -115,9 +114,8 @@ export class SettingsComponent implements OnInit { private autofillSettingsService: AutofillSettingsServiceAbstraction, private messagingService: MessagingService, private cryptoService: CryptoService, - private modalService: ModalService, private themeStateService: ThemeStateService, - private settingsService: SettingsService, + private domainSettingsService: DomainSettingsService, private dialogService: DialogService, private userVerificationService: UserVerificationServiceAbstraction, private biometricStateService: BiometricStateService, @@ -251,7 +249,7 @@ export class SettingsComponent implements OnInit { approveLoginRequests: (await this.stateService.getApproveLoginRequests()) ?? false, clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$), minimizeOnCopyToClipboard: await this.stateService.getMinimizeOnCopyToClipboard(), - enableFavicons: !(await this.stateService.getDisableFavicon()), + enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$), enableTray: await this.stateService.getEnableTray(), enableMinToTray: await this.stateService.getEnableMinimizeToTray(), enableCloseToTray: await this.stateService.getEnableCloseToTray(), @@ -498,7 +496,7 @@ export class SettingsComponent implements OnInit { } async saveFavicons() { - await this.settingsService.setDisableFavicon(!this.form.value.enableFavicons); + await this.domainSettingsService.setShowFavicons(this.form.value.enableFavicons); this.messagingService.send("refreshCiphers"); } diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index c674915b325..fa396ab313b 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -19,7 +19,6 @@ import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -123,7 +122,6 @@ export class AppComponent implements OnInit, OnDestroy { constructor( private broadcasterService: BroadcasterService, private folderService: InternalFolderService, - private settingsService: SettingsService, private syncService: SyncService, private passwordGenerationService: PasswordGenerationServiceAbstraction, private cipherService: CipherService, diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 9d6ff91666c..a1b74566279 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -9,7 +9,6 @@ import { Subject, switchMap, takeUntil, timer } from "rxjs"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -44,7 +43,6 @@ import { SingleOrgPolicy, TwoFactorAuthenticationPolicy, } from "./admin-console/organizations/policies"; -import { RouterService } from "./core"; const BroadcasterSubscriptionId = "AppComponent"; const IdleTimeout = 60000 * 10; // 10 minutes @@ -65,7 +63,6 @@ export class AppComponent implements OnDestroy, OnInit { @Inject(DOCUMENT) private document: Document, private broadcasterService: BroadcasterService, private folderService: InternalFolderService, - private settingsService: SettingsService, private syncService: SyncService, private passwordGenerationService: PasswordGenerationServiceAbstraction, private cipherService: CipherService, @@ -81,7 +78,6 @@ export class AppComponent implements OnDestroy, OnInit { private sanitizer: DomSanitizer, private searchService: SearchService, private notificationsService: NotificationsService, - private routerService: RouterService, private stateService: StateService, private eventUploadService: EventUploadService, private policyService: InternalPolicyService, diff --git a/apps/web/src/app/settings/preferences.component.ts b/apps/web/src/app/settings/preferences.component.ts index aa2bc22beac..7f9eabb6b3b 100644 --- a/apps/web/src/app/settings/preferences.component.ts +++ b/apps/web/src/app/settings/preferences.component.ts @@ -2,13 +2,12 @@ import { Component, OnInit } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { concatMap, filter, firstValueFrom, map, Observable, Subject, takeUntil, tap } from "rxjs"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -50,9 +49,8 @@ export class PreferencesComponent implements OnInit { private i18nService: I18nService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private platformUtilsService: PlatformUtilsService, - private messagingService: MessagingService, private themeStateService: ThemeStateService, - private settingsService: SettingsService, + private domainSettingsService: DomainSettingsService, private dialogService: DialogService, ) { this.vaultTimeoutOptions = [ @@ -137,7 +135,7 @@ export class PreferencesComponent implements OnInit { vaultTimeoutAction: await firstValueFrom( this.vaultTimeoutSettingsService.vaultTimeoutAction$(), ), - enableFavicons: !(await this.settingsService.getDisableFavicon()), + enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$), theme: await firstValueFrom(this.themeStateService.selectedTheme$), locale: (await firstValueFrom(this.i18nService.userSetLocale$)) ?? null, }; @@ -160,7 +158,7 @@ export class PreferencesComponent implements OnInit { values.vaultTimeout, values.vaultTimeoutAction, ); - await this.settingsService.setDisableFavicon(!values.enableFavicons); + await this.domainSettingsService.setShowFavicons(values.enableFavicons); await this.themeStateService.setSelectedTheme(values.theme); await this.i18nService.setLocale(values.locale); if (values.locale !== this.startingLocale) { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 8f33c501646..05659de073c 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -3,11 +3,11 @@ import { RouterModule } from "@angular/router"; import { applicationConfig, Meta, moduleMetadata, Story } from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -57,19 +57,19 @@ export default { useValue: { activeAccount$: new BehaviorSubject("1").asObservable(), accounts$: new BehaviorSubject({ "1": { profile: { name: "Foo" } } }).asObservable(), - async getDisableFavicon() { - return false; + async getShowFavicon() { + return true; }, } as Partial, }, { - provide: SettingsService, + provide: DomainSettingsService, useValue: { - disableFavicon$: new BehaviorSubject(false).asObservable(), - getDisableFavicon() { - return false; + showFavicons$: new BehaviorSubject(true).asObservable(), + getShowFavicon() { + return true; }, - } as Partial, + } as Partial, }, { provide: AvatarService, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index d8dcf08835e..a509897fd3a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -15,7 +15,6 @@ import { EventCollectionService as EventCollectionServiceAbstraction } from "@bi import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; -import { SettingsService as SettingsServiceAbstraction } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; @@ -172,7 +171,6 @@ import { EventCollectionService } from "@bitwarden/common/services/event/event-c import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service"; import { SearchService } from "@bitwarden/common/services/search.service"; -import { SettingsService } from "@bitwarden/common/services/settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; import { @@ -583,11 +581,6 @@ const typesafeProviders: Array = [ ], }), safeProvider({ provide: BroadcasterServiceAbstraction, useClass: BroadcasterService, deps: [] }), - safeProvider({ - provide: SettingsServiceAbstraction, - useClass: SettingsService, - deps: [StateServiceAbstraction], - }), safeProvider({ provide: VaultTimeoutSettingsServiceAbstraction, useClass: VaultTimeoutSettingsService, diff --git a/libs/angular/src/vault/components/icon.component.ts b/libs/angular/src/vault/components/icon.component.ts index 33cd2bc71a0..8323c55d4e4 100644 --- a/libs/angular/src/vault/components/icon.component.ts +++ b/libs/angular/src/vault/components/icon.component.ts @@ -8,7 +8,7 @@ import { Observable, } from "rxjs"; -import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -35,15 +35,15 @@ export class IconComponent implements OnInit { constructor( private environmentService: EnvironmentService, - private settingsService: SettingsService, + private domainSettingsService: DomainSettingsService, ) {} async ngOnInit() { const iconsUrl = this.environmentService.getIconsUrl(); this.data$ = combineLatest([ - this.settingsService.disableFavicon$.pipe(distinctUntilChanged()), + this.domainSettingsService.showFavicons$.pipe(distinctUntilChanged()), this.cipher$.pipe(filter((c) => c !== undefined)), - ]).pipe(map(([disableFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, disableFavicon))); + ]).pipe(map(([showFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, showFavicon))); } } diff --git a/libs/common/src/abstractions/settings.service.ts b/libs/common/src/abstractions/settings.service.ts deleted file mode 100644 index e9d8c8b683a..00000000000 --- a/libs/common/src/abstractions/settings.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Observable } from "rxjs"; - -export abstract class SettingsService { - disableFavicon$: Observable; - - setDisableFavicon: (value: boolean) => Promise; - getDisableFavicon: () => boolean; -} diff --git a/libs/common/src/autofill/services/domain-settings.service.ts b/libs/common/src/autofill/services/domain-settings.service.ts index 3131b9c50b5..6ef4d10c0a5 100644 --- a/libs/common/src/autofill/services/domain-settings.service.ts +++ b/libs/common/src/autofill/services/domain-settings.service.ts @@ -16,6 +16,10 @@ import { UserKeyDefinition, } from "../../platform/state"; +const SHOW_FAVICONS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "showFavicons", { + deserializer: (value: boolean) => value ?? true, +}); + const NEVER_DOMAINS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "neverDomains", { deserializer: (value: NeverDomains) => value ?? null, }); @@ -34,6 +38,8 @@ const DEFAULT_URI_MATCH_STRATEGY = new KeyDefinition( ); export abstract class DomainSettingsService { + showFavicons$: Observable; + setShowFavicons: (newValue: boolean) => Promise; neverDomains$: Observable; setNeverDomains: (newValue: NeverDomains) => Promise; equivalentDomains$: Observable; @@ -44,6 +50,9 @@ export abstract class DomainSettingsService { } export class DefaultDomainSettingsService implements DomainSettingsService { + private showFaviconsState: GlobalState; + readonly showFavicons$: Observable; + private neverDomainsState: GlobalState; readonly neverDomains$: Observable; @@ -54,6 +63,9 @@ export class DefaultDomainSettingsService implements DomainSettingsService { readonly defaultUriMatchStrategy$: Observable; constructor(private stateProvider: StateProvider) { + this.showFaviconsState = this.stateProvider.getGlobal(SHOW_FAVICONS); + this.showFavicons$ = this.showFaviconsState.state$.pipe(map((x) => x ?? true)); + this.neverDomainsState = this.stateProvider.getGlobal(NEVER_DOMAINS); this.neverDomains$ = this.neverDomainsState.state$.pipe(map((x) => x ?? null)); @@ -66,6 +78,10 @@ export class DefaultDomainSettingsService implements DomainSettingsService { ); } + async setShowFavicons(newValue: boolean): Promise { + await this.showFaviconsState.update(() => newValue); + } + async setNeverDomains(newValue: NeverDomains): Promise { await this.neverDomainsState.update(() => newValue); } diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 4c7e38f8e85..3413afe1825 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -170,14 +170,6 @@ export abstract class StateService { * @deprecated Do not call this directly, use SendService */ setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this, use SettingsService - */ - getDisableFavicon: (options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this, use SettingsService - */ - setDisableFavicon: (value: boolean, options?: StorageOptions) => Promise; getDisableGa: (options?: StorageOptions) => Promise; setDisableGa: (value: boolean, options?: StorageOptions) => Promise; getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/common/src/platform/models/domain/global-state.ts index 0b018aa36b5..b27bac3bd42 100644 --- a/libs/common/src/platform/models/domain/global-state.ts +++ b/libs/common/src/platform/models/domain/global-state.ts @@ -10,7 +10,6 @@ export class GlobalState { theme?: ThemeType = ThemeType.System; window?: WindowState = new WindowState(); twoFactorToken?: string; - disableFavicon?: boolean; biometricFingerprintValidated?: boolean; vaultTimeout?: number; vaultTimeoutAction?: string; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 6694b6ab3f7..31d69e868bf 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -710,27 +710,6 @@ export class StateService< ); } - async getDisableFavicon(options?: StorageOptions): Promise { - return ( - ( - await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ) - )?.disableFavicon ?? false - ); - } - - async setDisableFavicon(value: boolean, options?: StorageOptions): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - globals.disableFavicon = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - async getDisableGa(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) diff --git a/libs/common/src/services/settings.service.ts b/libs/common/src/services/settings.service.ts deleted file mode 100644 index 9a4d04a147d..00000000000 --- a/libs/common/src/services/settings.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { BehaviorSubject, concatMap } from "rxjs"; - -import { SettingsService as SettingsServiceAbstraction } from "../abstractions/settings.service"; -import { StateService } from "../platform/abstractions/state.service"; -import { Utils } from "../platform/misc/utils"; - -export class SettingsService implements SettingsServiceAbstraction { - protected _disableFavicon = new BehaviorSubject(null); - - disableFavicon$ = this._disableFavicon.asObservable(); - - constructor(private stateService: StateService) { - this.stateService.activeAccountUnlocked$ - .pipe( - concatMap(async (unlocked) => { - if (Utils.global.bitwardenContainerService == null) { - return; - } - - if (!unlocked) { - return; - } - - const disableFavicon = await this.stateService.getDisableFavicon(); - - this._disableFavicon.next(disableFavicon); - }), - ) - .subscribe(); - } - - async setDisableFavicon(value: boolean) { - this._disableFavicon.next(value); - await this.stateService.setDisableFavicon(value); - } - - getDisableFavicon(): boolean { - return this._disableFavicon.getValue(); - } -} diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 3bb947d0bd2..000f08b392b 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -37,6 +37,7 @@ import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing- import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { OrganizationMigrator } from "./migrations/40-move-organization-state-to-state-provider"; import { EventCollectionMigrator } from "./migrations/41-move-event-collection-to-state-provider"; +import { EnableFaviconMigrator } from "./migrations/42-move-enable-favicon-to-domain-settings-state-provider"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; @@ -45,7 +46,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 41; +export const CURRENT_VERSION = 42; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -88,7 +89,8 @@ export function createMigrationBuilder() { .with(TokenServiceStateProviderMigrator, 37, 38) .with(MoveBillingAccountProfileMigrator, 38, 39) .with(OrganizationMigrator, 39, 40) - .with(EventCollectionMigrator, 40, CURRENT_VERSION); + .with(EventCollectionMigrator, 40, 41) + .with(EnableFaviconMigrator, 41, 42); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts new file mode 100644 index 00000000000..ddb3bd99072 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts @@ -0,0 +1,108 @@ +import { MockProxy } from "jest-mock-extended"; + +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { EnableFaviconMigrator } from "./42-move-enable-favicon-to-domain-settings-state-provider"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + disableFavicon: true, + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + settings: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + global_domainSettings_showFavicons: false, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + settings: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +const showFaviconsKeyDefinition: KeyDefinitionLike = { + stateDefinition: { + name: "domainSettings", + }, + key: "showFavicons", +}; + +describe("EnableFaviconMigrator", () => { + let helper: MockProxy; + let sut: EnableFaviconMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 41); + sut = new EnableFaviconMigrator(41, 42); + }); + + it("should remove global disableFavicon", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + otherStuff: "otherStuff1", + }); + }); + + it("should set global showFavicons", async () => { + await sut.migrate(helper); + + expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + expect(helper.setToGlobal).toHaveBeenCalledWith(showFaviconsKeyDefinition, false); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 42); + sut = new EnableFaviconMigrator(41, 42); + }); + + it("should null global showFavicons", async () => { + await sut.rollback(helper); + + expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + expect(helper.setToGlobal).toHaveBeenCalledWith(showFaviconsKeyDefinition, null); + }); + + it("should add global disableFavicon back", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + disableFavicon: true, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts b/libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts new file mode 100644 index 00000000000..7da988ad8f6 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts @@ -0,0 +1,45 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedGlobalState = { + disableFavicon?: boolean; +}; + +const ShowFaviconDefinition: KeyDefinitionLike = { + stateDefinition: { + name: "domainSettings", + }, + key: "showFavicons", +}; + +export class EnableFaviconMigrator extends Migrator<41, 42> { + async migrate(helper: MigrationHelper): Promise { + // global state ("disableFavicon" -> "showFavicons") + const globalState = await helper.get("global"); + + if (globalState?.disableFavicon != null) { + await helper.setToGlobal(ShowFaviconDefinition, !globalState.disableFavicon); + + // delete `disableFavicon` from state global + delete globalState.disableFavicon; + + await helper.set("global", globalState); + } + } + + async rollback(helper: MigrationHelper): Promise { + // global state ("showFavicons" -> "disableFavicon") + const globalState = (await helper.get("global")) || {}; + const showFavicons: boolean = await helper.getFromGlobal(ShowFaviconDefinition); + + if (showFavicons != null) { + await helper.set("global", { + ...globalState, + disableFavicon: !showFavicons, + }); + + // remove the global state provider framework key for `showFavicons` + await helper.setToGlobal(ShowFaviconDefinition, null); + } + } +} diff --git a/libs/common/src/vault/icon/build-cipher-icon.ts b/libs/common/src/vault/icon/build-cipher-icon.ts index 80bc5cbe3ae..9e6e671f44d 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.ts @@ -2,12 +2,7 @@ import { Utils } from "../../platform/misc/utils"; import { CipherType } from "../enums/cipher-type"; import { CipherView } from "../models/view/cipher.view"; -export function buildCipherIcon( - iconsServerUrl: string, - cipher: CipherView, - isFaviconDisabled: boolean, -) { - const imageEnabled = !isFaviconDisabled; +export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, showFavicon: boolean) { let icon; let image; let fallbackImage = ""; @@ -38,17 +33,17 @@ export function buildCipherIcon( icon = "bwi-apple"; image = null; } else if ( - imageEnabled && + showFavicon && hostnameUri.indexOf("://") === -1 && hostnameUri.indexOf(".") > -1 ) { hostnameUri = `http://${hostnameUri}`; isWebsite = true; - } else if (imageEnabled) { + } else if (showFavicon) { isWebsite = hostnameUri.indexOf("http") === 0 && hostnameUri.indexOf(".") > -1; } - if (imageEnabled && isWebsite) { + if (showFavicon && isWebsite) { try { image = `${iconsServerUrl}/${Utils.getHostname(hostnameUri)}/icon.png`; fallbackImage = "images/bwi-globe.png"; @@ -65,7 +60,7 @@ export function buildCipherIcon( break; case CipherType.Card: icon = "bwi-credit-card"; - if (imageEnabled && cipher.card.brand in cardIcons) { + if (showFavicon && cipher.card.brand in cardIcons) { icon = `credit-card-icon ${cardIcons[cipher.card.brand]}`; } break; @@ -77,7 +72,7 @@ export function buildCipherIcon( } return { - imageEnabled, + imageEnabled: showFavicon, image, fallbackImage, icon, From ee22d07474b7478520893b38e5a573e3cf0c82ef Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 Mar 2024 14:11:09 +0100 Subject: [PATCH 34/41] [deps] Platform: Update Rust crate tokio to v1.36.0 (#8194) * [deps] Platform: Update Rust crate tokio to v1.36.0 * Tokio is not a direct dependency --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Matt Gibson --- apps/desktop/desktop_native/Cargo.lock | 77 ++------------------------ apps/desktop/desktop_native/Cargo.toml | 1 - 2 files changed, 6 insertions(+), 72 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 085ec3f872f..138394886d3 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -130,12 +130,6 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1e5f035d16fc623ae5f74981db80a439803888314e3a555fd6f04acd51a3205" -[[package]] -name = "bytes" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" - [[package]] name = "cbc" version = "0.1.2" @@ -333,7 +327,6 @@ dependencies = [ "security-framework-sys", "sha2", "thiserror", - "tokio", "typenum", "widestring", "windows", @@ -377,7 +370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -772,17 +765,6 @@ dependencies = [ "adler", ] -[[package]] -name = "mio" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.48.0", -] - [[package]] name = "napi" version = "2.13.3" @@ -925,7 +907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57119c3b893986491ec9aa85056780d3a0f3cf4da7cc09dd3650dbd6c6738fb9" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -1114,7 +1096,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -1204,15 +1186,6 @@ dependencies = [ "digest", ] -[[package]] -name = "signal-hook-registry" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" -dependencies = [ - "libc", -] - [[package]] name = "slab" version = "0.4.9" @@ -1228,16 +1201,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" -[[package]] -name = "socket2" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "str-buf" version = "1.0.6" @@ -1295,7 +1258,7 @@ dependencies = [ "fastrand", "redox_syscall", "rustix", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -1329,32 +1292,13 @@ dependencies = [ [[package]] name = "tokio" -version = "1.32.0" +version = "1.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" dependencies = [ "backtrace", - "bytes", - "libc", - "mio", "num_cpus", - "parking_lot", "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.48.0", -] - -[[package]] -name = "tokio-macros" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.38", ] [[package]] @@ -1606,15 +1550,6 @@ dependencies = [ "windows-targets 0.52.4", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 19bcf52f6eb..2542e4d214a 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -25,7 +25,6 @@ retry = "=2.0.0" scopeguard = "=1.2.0" sha2 = "=0.10.8" thiserror = "=1.0.51" -tokio = { version = "=1.32.0", features = ["full"] } typenum = "=1.17.0" [build-dependencies] From 61b375973622da17f619e42738b96e263aac3722 Mon Sep 17 00:00:00 2001 From: aj-rosado <109146700+aj-rosado@users.noreply.github.com> Date: Tue, 19 Mar 2024 14:19:41 +0000 Subject: [PATCH 35/41] [PM-6334] Passing CollectionView or FolderView from Import component to ImportService (#8291) * Passing CollectionView or FolderView from Import component to ImportService * Corrected import service tests * Added tests to validate if the incorrect object type error is thrown on setImportTarget --- .../src/components/import.component.html | 4 +- .../services/import.service.abstraction.ts | 5 +- .../src/services/import.service.spec.ts | 66 ++++++++++++++----- libs/importer/src/services/import.service.ts | 34 +++++----- 4 files changed, 71 insertions(+), 38 deletions(-) diff --git a/libs/importer/src/components/import.component.html b/libs/importer/src/components/import.component.html index 6c24b80f92d..836a1d9a1a1 100644 --- a/libs/importer/src/components/import.component.html +++ b/libs/importer/src/components/import.component.html @@ -37,7 +37,7 @@ @@ -46,7 +46,7 @@ diff --git a/libs/importer/src/services/import.service.abstraction.ts b/libs/importer/src/services/import.service.abstraction.ts index 95208c9b99f..dc77e76390e 100644 --- a/libs/importer/src/services/import.service.abstraction.ts +++ b/libs/importer/src/services/import.service.abstraction.ts @@ -1,3 +1,6 @@ +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; + import { Importer } from "../importers/importer"; import { ImportOption, ImportType } from "../models/import-options"; import { ImportResult } from "../models/import-result"; @@ -10,7 +13,7 @@ export abstract class ImportServiceAbstraction { importer: Importer, fileContents: string, organizationId?: string, - selectedImportTarget?: string, + selectedImportTarget?: FolderView | CollectionView, canAccessImportExport?: boolean, ) => Promise; getImporter: ( diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index 6a2e0a339c7..a95b74d792c 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -86,7 +86,7 @@ describe("ImportService", () => { }); it("empty importTarget does nothing", async () => { - await importService["setImportTarget"](importResult, null, ""); + await importService["setImportTarget"](importResult, null, null); expect(importResult.folders.length).toBe(0); }); @@ -99,9 +99,9 @@ describe("ImportService", () => { Promise.resolve([mockImportTargetFolder]), ); - await importService["setImportTarget"](importResult, null, "myImportTarget"); + await importService["setImportTarget"](importResult, null, mockImportTargetFolder); expect(importResult.folders.length).toBe(1); - expect(importResult.folders[0].name).toBe("myImportTarget"); + expect(importResult.folders[0]).toBe(mockImportTargetFolder); }); const mockFolder1 = new FolderView(); @@ -119,16 +119,18 @@ describe("ImportService", () => { mockFolder2, ]); - const myImportTarget = "myImportTarget"; - importResult.folders.push(mockFolder1); importResult.folders.push(mockFolder2); - await importService["setImportTarget"](importResult, null, myImportTarget); + await importService["setImportTarget"](importResult, null, mockImportTargetFolder); expect(importResult.folders.length).toBe(3); - expect(importResult.folders[0].name).toBe(myImportTarget); - expect(importResult.folders[1].name).toBe(`${myImportTarget}/${mockFolder1.name}`); - expect(importResult.folders[2].name).toBe(`${myImportTarget}/${mockFolder2.name}`); + expect(importResult.folders[0]).toBe(mockImportTargetFolder); + expect(importResult.folders[1].name).toBe( + `${mockImportTargetFolder.name}/${mockFolder1.name}`, + ); + expect(importResult.folders[2].name).toBe( + `${mockImportTargetFolder.name}/${mockFolder2.name}`, + ); }); const mockImportTargetCollection = new CollectionView(); @@ -152,9 +154,13 @@ describe("ImportService", () => { mockCollection1, ]); - await importService["setImportTarget"](importResult, organizationId, "myImportTarget"); + await importService["setImportTarget"]( + importResult, + organizationId, + mockImportTargetCollection, + ); expect(importResult.collections.length).toBe(1); - expect(importResult.collections[0].name).toBe("myImportTarget"); + expect(importResult.collections[0]).toBe(mockImportTargetCollection); }); it("passing importTarget sets it as new root for all existing collections", async () => { @@ -164,16 +170,42 @@ describe("ImportService", () => { mockCollection2, ]); - const myImportTarget = "myImportTarget"; - importResult.collections.push(mockCollection1); importResult.collections.push(mockCollection2); - await importService["setImportTarget"](importResult, organizationId, myImportTarget); + await importService["setImportTarget"]( + importResult, + organizationId, + mockImportTargetCollection, + ); expect(importResult.collections.length).toBe(3); - expect(importResult.collections[0].name).toBe(myImportTarget); - expect(importResult.collections[1].name).toBe(`${myImportTarget}/${mockCollection1.name}`); - expect(importResult.collections[2].name).toBe(`${myImportTarget}/${mockCollection2.name}`); + expect(importResult.collections[0]).toBe(mockImportTargetCollection); + expect(importResult.collections[1].name).toBe( + `${mockImportTargetCollection.name}/${mockCollection1.name}`, + ); + expect(importResult.collections[2].name).toBe( + `${mockImportTargetCollection.name}/${mockCollection2.name}`, + ); + }); + + it("passing importTarget as null on setImportTarget with organizationId throws error", async () => { + const setImportTargetMethod = importService["setImportTarget"]( + null, + organizationId, + new Object() as FolderView, + ); + + await expect(setImportTargetMethod).rejects.toThrow("Error assigning target collection"); + }); + + it("passing importTarget as null on setImportTarget throws error", async () => { + const setImportTargetMethod = importService["setImportTarget"]( + null, + "", + new Object() as CollectionView, + ); + + await expect(setImportTargetMethod).rejects.toThrow("Error assigning target folder"); }); }); }); diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index e26b768ab67..a6fd233dcf6 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -110,7 +110,7 @@ export class ImportService implements ImportServiceAbstraction { importer: Importer, fileContents: string, organizationId: string = null, - selectedImportTarget: string = null, + selectedImportTarget: FolderView | CollectionView = null, canAccessImportExport: boolean, ): Promise { let importResult: ImportResult; @@ -147,11 +147,7 @@ export class ImportService implements ImportServiceAbstraction { } } - if ( - organizationId && - Utils.isNullOrWhitespace(selectedImportTarget) && - !canAccessImportExport - ) { + if (organizationId && !selectedImportTarget && !canAccessImportExport) { const hasUnassignedCollections = importResult.collectionRelationships.length < importResult.ciphers.length; if (hasUnassignedCollections) { @@ -428,29 +424,30 @@ export class ImportService implements ImportServiceAbstraction { private async setImportTarget( importResult: ImportResult, organizationId: string, - importTarget: string, + importTarget: FolderView | CollectionView, ) { - if (Utils.isNullOrWhitespace(importTarget)) { + if (!importTarget) { return; } if (organizationId) { - const collectionViews: CollectionView[] = await this.collectionService.getAllDecrypted(); - const targetCollection = collectionViews.find((c) => c.id === importTarget); + if (!(importTarget instanceof CollectionView)) { + throw new Error("Error assigning target collection"); + } const noCollectionRelationShips: [number, number][] = []; importResult.ciphers.forEach((c, index) => { if (!Array.isArray(c.collectionIds) || c.collectionIds.length == 0) { - c.collectionIds = [targetCollection.id]; + c.collectionIds = [importTarget.id]; noCollectionRelationShips.push([index, 0]); } }); const collections: CollectionView[] = [...importResult.collections]; - importResult.collections = [targetCollection]; + importResult.collections = [importTarget as CollectionView]; collections.map((x) => { const f = new CollectionView(); - f.name = `${targetCollection.name}/${x.name}`; + f.name = `${importTarget.name}/${x.name}`; importResult.collections.push(f); }); @@ -463,21 +460,22 @@ export class ImportService implements ImportServiceAbstraction { return; } - const folderViews = await this.folderService.getAllDecryptedFromState(); - const targetFolder = folderViews.find((f) => f.id === importTarget); + if (!(importTarget instanceof FolderView)) { + throw new Error("Error assigning target folder"); + } const noFolderRelationShips: [number, number][] = []; importResult.ciphers.forEach((c, index) => { if (Utils.isNullOrEmpty(c.folderId)) { - c.folderId = targetFolder.id; + c.folderId = importTarget.id; noFolderRelationShips.push([index, 0]); } }); const folders: FolderView[] = [...importResult.folders]; - importResult.folders = [targetFolder]; + importResult.folders = [importTarget as FolderView]; folders.map((x) => { - const newFolderName = `${targetFolder.name}/${x.name}`; + const newFolderName = `${importTarget.name}/${x.name}`; const f = new FolderView(); f.name = newFolderName; importResult.folders.push(f); From ea0035f658fde9d433910d877830223277757b31 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Tue, 19 Mar 2024 17:20:57 +0100 Subject: [PATCH 36/41] [PM-6755] Fix password generation defaults on CLI (#8308) * Fix minSpecial for pwd generation being set to 1 instead of zero * Use less magic numbers --------- Co-authored-by: Daniel James Smith --- apps/cli/src/tools/generate.command.ts | 20 +++++++++++++++---- .../password/password-generation-options.ts | 4 ++-- .../password/password-generation.service.ts | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/tools/generate.command.ts b/apps/cli/src/tools/generate.command.ts index 4763c6992ed..2bbfb02267b 100644 --- a/apps/cli/src/tools/generate.command.ts +++ b/apps/cli/src/tools/generate.command.ts @@ -1,6 +1,9 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { DefaultPassphraseGenerationOptions } from "@bitwarden/common/tools/generator/passphrase"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { + DefaultPasswordGenerationOptions, + PasswordGenerationServiceAbstraction, +} from "@bitwarden/common/tools/generator/password"; import { PasswordGeneratorOptions } from "@bitwarden/common/tools/generator/password/password-generator-options"; import { Response } from "../models/response"; @@ -64,7 +67,10 @@ class Options { this.capitalize = CliUtils.convertBooleanOption(passedOptions?.capitalize); this.includeNumber = CliUtils.convertBooleanOption(passedOptions?.includeNumber); this.ambiguous = CliUtils.convertBooleanOption(passedOptions?.ambiguous); - this.length = CliUtils.convertNumberOption(passedOptions?.length, 14); + this.length = CliUtils.convertNumberOption( + passedOptions?.length, + DefaultPasswordGenerationOptions.length, + ); this.type = passedOptions?.passphrase ? "passphrase" : "password"; this.separator = CliUtils.convertStringOption( passedOptions?.separator, @@ -74,8 +80,14 @@ class Options { passedOptions?.words, DefaultPassphraseGenerationOptions.numWords, ); - this.minNumber = CliUtils.convertNumberOption(passedOptions?.minNumber, 1); - this.minSpecial = CliUtils.convertNumberOption(passedOptions?.minSpecial, 1); + this.minNumber = CliUtils.convertNumberOption( + passedOptions?.minNumber, + DefaultPasswordGenerationOptions.minNumber, + ); + this.minSpecial = CliUtils.convertNumberOption( + passedOptions?.minSpecial, + DefaultPasswordGenerationOptions.minSpecial, + ); if (!this.uppercase && !this.lowercase && !this.special && !this.number) { this.lowercase = true; diff --git a/libs/common/src/tools/generator/password/password-generation-options.ts b/libs/common/src/tools/generator/password/password-generation-options.ts index 55b27e4e7a0..a48eeb77c6e 100644 --- a/libs/common/src/tools/generator/password/password-generation-options.ts +++ b/libs/common/src/tools/generator/password/password-generation-options.ts @@ -78,6 +78,6 @@ export const DefaultPasswordGenerationOptions: Partial Date: Tue, 19 Mar 2024 20:49:59 +0100 Subject: [PATCH 37/41] [PM-6790][Tech Debt] Cleanup export web component (#8323) * Remove formPromise and use bitSubmit * Use formGroup.invalid instead of !valid * Move variables related to encrypted exports into base component. * Migrate to use new userVerificationDialogComponent --------- Co-authored-by: Daniel James Smith --- .../tools/vault-export/export.component.html | 10 +--- .../tools/vault-export/export.component.ts | 58 ++++++++----------- .../export/components/export.component.ts | 21 ++++++- 3 files changed, 45 insertions(+), 44 deletions(-) diff --git a/apps/web/src/app/tools/vault-export/export.component.html b/apps/web/src/app/tools/vault-export/export.component.html index 9d615d329d7..8ed82b9fd9e 100644 --- a/apps/web/src/app/tools/vault-export/export.component.html +++ b/apps/web/src/app/tools/vault-export/export.component.html @@ -1,13 +1,7 @@ -
    + {{ "personalVaultExportPolicyInEffect" | i18n }} @@ -110,9 +104,9 @@