mirror of
https://github.com/bitwarden/browser.git
synced 2024-08-27 23:31:41 +02:00
[PM-6426] Merging main into branch
This commit is contained in:
commit
7acb24f724
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -61,6 +61,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev
|
|||
libs/angular/src/billing @bitwarden/team-billing-dev
|
||||
libs/common/src/billing @bitwarden/team-billing-dev
|
||||
libs/billing @bitwarden/team-billing-dev
|
||||
bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev
|
||||
|
||||
## Platform team files ##
|
||||
apps/browser/src/platform @bitwarden/team-platform-dev
|
||||
|
|
|
@ -147,6 +147,7 @@ import {
|
|||
} from "@bitwarden/common/tools/password-strength";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service";
|
||||
import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service";
|
||||
import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
@ -278,6 +279,7 @@ export default class MainBackground {
|
|||
eventUploadService: EventUploadServiceAbstraction;
|
||||
policyService: InternalPolicyServiceAbstraction;
|
||||
sendService: InternalSendServiceAbstraction;
|
||||
sendStateProvider: SendStateProvider;
|
||||
fileUploadService: FileUploadServiceAbstraction;
|
||||
cipherFileUploadService: CipherFileUploadServiceAbstraction;
|
||||
organizationService: InternalOrganizationServiceAbstraction;
|
||||
|
@ -715,11 +717,14 @@ export default class MainBackground {
|
|||
logoutCallback,
|
||||
);
|
||||
this.containerService = new ContainerService(this.cryptoService, this.encryptService);
|
||||
|
||||
this.sendStateProvider = new SendStateProvider(this.stateProvider);
|
||||
this.sendService = new SendService(
|
||||
this.cryptoService,
|
||||
this.i18nService,
|
||||
this.keyGenerationService,
|
||||
this.stateService,
|
||||
this.sendStateProvider,
|
||||
this.encryptService,
|
||||
);
|
||||
this.sendApiService = new SendApiService(
|
||||
this.apiService,
|
||||
|
|
|
@ -5,6 +5,10 @@ import {
|
|||
CryptoServiceInitOptions,
|
||||
cryptoServiceFactory,
|
||||
} from "../../platform/background/service-factories/crypto-service.factory";
|
||||
import {
|
||||
EncryptServiceInitOptions,
|
||||
encryptServiceFactory,
|
||||
} from "../../platform/background/service-factories/encrypt-service.factory";
|
||||
import {
|
||||
FactoryOptions,
|
||||
CachedServices,
|
||||
|
@ -18,10 +22,11 @@ import {
|
|||
KeyGenerationServiceInitOptions,
|
||||
keyGenerationServiceFactory,
|
||||
} from "../../platform/background/service-factories/key-generation-service.factory";
|
||||
|
||||
import {
|
||||
stateServiceFactory,
|
||||
StateServiceInitOptions,
|
||||
} from "../../platform/background/service-factories/state-service.factory";
|
||||
SendStateProviderInitOptions,
|
||||
sendStateProviderFactory,
|
||||
} from "./send-state-provider.factory";
|
||||
|
||||
type SendServiceFactoryOptions = FactoryOptions;
|
||||
|
||||
|
@ -29,7 +34,8 @@ export type SendServiceInitOptions = SendServiceFactoryOptions &
|
|||
CryptoServiceInitOptions &
|
||||
I18nServiceInitOptions &
|
||||
KeyGenerationServiceInitOptions &
|
||||
StateServiceInitOptions;
|
||||
SendStateProviderInitOptions &
|
||||
EncryptServiceInitOptions;
|
||||
|
||||
export function sendServiceFactory(
|
||||
cache: { sendService?: InternalSendService } & CachedServices,
|
||||
|
@ -44,7 +50,8 @@ export function sendServiceFactory(
|
|||
await cryptoServiceFactory(cache, opts),
|
||||
await i18nServiceFactory(cache, opts),
|
||||
await keyGenerationServiceFactory(cache, opts),
|
||||
await stateServiceFactory(cache, opts),
|
||||
await sendStateProviderFactory(cache, opts),
|
||||
await encryptServiceFactory(cache, opts),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider";
|
||||
|
||||
import {
|
||||
CachedServices,
|
||||
FactoryOptions,
|
||||
factory,
|
||||
} from "../../platform/background/service-factories/factory-options";
|
||||
import {
|
||||
StateProviderInitOptions,
|
||||
stateProviderFactory,
|
||||
} from "../../platform/background/service-factories/state-provider.factory";
|
||||
|
||||
type SendStateProviderFactoryOptions = FactoryOptions;
|
||||
|
||||
export type SendStateProviderInitOptions = SendStateProviderFactoryOptions &
|
||||
StateProviderInitOptions;
|
||||
|
||||
export function sendStateProviderFactory(
|
||||
cache: { sendStateProvider?: SendStateProvider } & CachedServices,
|
||||
opts: SendStateProviderInitOptions,
|
||||
): Promise<SendStateProvider> {
|
||||
return factory(
|
||||
cache,
|
||||
"sendStateProvider",
|
||||
opts,
|
||||
async () => new SendStateProvider(await stateProviderFactory(cache, opts)),
|
||||
);
|
||||
}
|
|
@ -1,38 +1,35 @@
|
|||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
|
||||
import MainBackground from "../background/main.background";
|
||||
|
||||
import { BrowserApi } from "./browser/browser-api";
|
||||
import {
|
||||
contextMenusClickedListener,
|
||||
onCommandListener,
|
||||
onInstallListener,
|
||||
runtimeMessageListener,
|
||||
windowsOnFocusChangedListener,
|
||||
tabsOnActivatedListener,
|
||||
tabsOnReplacedListener,
|
||||
tabsOnUpdatedListener,
|
||||
} from "./listeners";
|
||||
|
||||
if (BrowserApi.isManifestVersion(3)) {
|
||||
chrome.commands.onCommand.addListener(onCommandListener);
|
||||
chrome.runtime.onInstalled.addListener(onInstallListener);
|
||||
chrome.windows.onFocusChanged.addListener(windowsOnFocusChangedListener);
|
||||
chrome.tabs.onActivated.addListener(tabsOnActivatedListener);
|
||||
chrome.tabs.onReplaced.addListener(tabsOnReplacedListener);
|
||||
chrome.tabs.onUpdated.addListener(tabsOnUpdatedListener);
|
||||
chrome.contextMenus.onClicked.addListener(contextMenusClickedListener);
|
||||
BrowserApi.messageListener(
|
||||
"runtime.background",
|
||||
(message: { command: string }, sender, sendResponse) => {
|
||||
// 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
|
||||
runtimeMessageListener(message, sender);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bitwardenMain = ((self as any).bitwardenMain = new MainBackground());
|
||||
// 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
|
||||
bitwardenMain.bootstrap().then(() => {
|
||||
const logService = new ConsoleLogService(false);
|
||||
const bitwardenMain = ((self as any).bitwardenMain = new MainBackground());
|
||||
bitwardenMain
|
||||
.bootstrap()
|
||||
.then(() => {
|
||||
// Finished bootstrapping
|
||||
});
|
||||
if (BrowserApi.isManifestVersion(3)) {
|
||||
startHeartbeat().catch((error) => logService.error(error));
|
||||
}
|
||||
})
|
||||
.catch((error) => logService.error(error));
|
||||
|
||||
/**
|
||||
* Tracks when a service worker was last alive and extends the service worker
|
||||
* lifetime by writing the current time to extension storage every 20 seconds.
|
||||
*/
|
||||
async function runHeartbeat() {
|
||||
await chrome.storage.local.set({ "last-heartbeat": new Date().getTime() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the heartbeat interval which keeps the service worker alive.
|
||||
*/
|
||||
async function startHeartbeat() {
|
||||
// Run the heartbeat once at service worker startup, then again every 20 seconds.
|
||||
runHeartbeat()
|
||||
.then(() => setInterval(runHeartbeat, 20 * 1000))
|
||||
.catch((error) => logService.error(error));
|
||||
}
|
||||
|
|
|
@ -3,22 +3,9 @@ import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage
|
|||
|
||||
import { Account } from "../../../models/account";
|
||||
import { BrowserComponentState } from "../../../models/browserComponentState";
|
||||
import { BrowserGroupingsComponentState } from "../../../models/browserGroupingsComponentState";
|
||||
import { BrowserSendComponentState } from "../../../models/browserSendComponentState";
|
||||
|
||||
export abstract class BrowserStateService extends BaseStateServiceAbstraction<Account> {
|
||||
getBrowserGroupingComponentState: (
|
||||
options?: StorageOptions,
|
||||
) => Promise<BrowserGroupingsComponentState>;
|
||||
setBrowserGroupingComponentState: (
|
||||
value: BrowserGroupingsComponentState,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
getBrowserVaultItemsComponentState: (options?: StorageOptions) => Promise<BrowserComponentState>;
|
||||
setBrowserVaultItemsComponentState: (
|
||||
value: BrowserComponentState,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
getBrowserSendComponentState: (options?: StorageOptions) => Promise<BrowserSendComponentState>;
|
||||
setBrowserSendComponentState: (
|
||||
value: BrowserSendComponentState,
|
||||
|
|
|
@ -18,7 +18,6 @@ import { UserId } from "@bitwarden/common/types/guid";
|
|||
|
||||
import { Account } from "../../models/account";
|
||||
import { BrowserComponentState } from "../../models/browserComponentState";
|
||||
import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState";
|
||||
import { BrowserSendComponentState } from "../../models/browserSendComponentState";
|
||||
|
||||
import { BrowserStateService } from "./browser-state.service";
|
||||
|
@ -86,27 +85,6 @@ describe("Browser State Service", () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe("getBrowserGroupingComponentState", () => {
|
||||
it("should return a BrowserGroupingsComponentState", async () => {
|
||||
state.accounts[userId].groupings = new BrowserGroupingsComponentState();
|
||||
|
||||
const actual = await sut.getBrowserGroupingComponentState();
|
||||
expect(actual).toBeInstanceOf(BrowserGroupingsComponentState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserVaultItemsComponentState", () => {
|
||||
it("should return a BrowserComponentState", async () => {
|
||||
const componentState = new BrowserComponentState();
|
||||
componentState.scrollY = 0;
|
||||
componentState.searchText = "test";
|
||||
state.accounts[userId].ciphers = componentState;
|
||||
|
||||
const actual = await sut.getBrowserVaultItemsComponentState();
|
||||
expect(actual).toStrictEqual(componentState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserSendComponentState", () => {
|
||||
it("should return a BrowserSendComponentState", async () => {
|
||||
const sendState = new BrowserSendComponentState();
|
||||
|
|
|
@ -16,7 +16,6 @@ import { StateService as BaseStateService } from "@bitwarden/common/platform/ser
|
|||
|
||||
import { Account } from "../../models/account";
|
||||
import { BrowserComponentState } from "../../models/browserComponentState";
|
||||
import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState";
|
||||
import { BrowserSendComponentState } from "../../models/browserSendComponentState";
|
||||
import { BrowserApi } from "../browser/browser-api";
|
||||
import { browserSession, sessionSync } from "../decorators/session-sync-observable";
|
||||
|
@ -116,50 +115,6 @@ export class BrowserStateService
|
|||
);
|
||||
}
|
||||
|
||||
async getBrowserGroupingComponentState(
|
||||
options?: StorageOptions,
|
||||
): Promise<BrowserGroupingsComponentState> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.groupings;
|
||||
}
|
||||
|
||||
async setBrowserGroupingComponentState(
|
||||
value: BrowserGroupingsComponentState,
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
account.groupings = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getBrowserVaultItemsComponentState(
|
||||
options?: StorageOptions,
|
||||
): Promise<BrowserComponentState> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.ciphers;
|
||||
}
|
||||
|
||||
async setBrowserVaultItemsComponentState(
|
||||
value: BrowserComponentState,
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
account.ciphers = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getBrowserSendComponentState(options?: StorageOptions): Promise<BrowserSendComponentState> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
|
|
|
@ -12,6 +12,7 @@ import { BrowserApi } from "../platform/browser/browser-api";
|
|||
import { ZonedMessageListenerService } from "../platform/browser/zoned-message-listener.service";
|
||||
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
|
||||
import { ForegroundPlatformUtilsService } from "../platform/services/platform-utils/foreground-platform-utils.service";
|
||||
import { VaultBrowserStateService } from "../vault/services/vault-browser-state.service";
|
||||
|
||||
import { routerTransition } from "./app-routing.animations";
|
||||
import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-dialog.component";
|
||||
|
@ -37,6 +38,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
private i18nService: I18nService,
|
||||
private router: Router,
|
||||
private stateService: BrowserStateService,
|
||||
private vaultBrowserStateService: VaultBrowserStateService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private ngZone: NgZone,
|
||||
private platformUtilsService: ForegroundPlatformUtilsService,
|
||||
|
@ -227,8 +229,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
await Promise.all([
|
||||
this.stateService.setBrowserGroupingComponentState(null),
|
||||
this.stateService.setBrowserVaultItemsComponentState(null),
|
||||
this.vaultBrowserStateService.setBrowserGroupingsComponentState(null),
|
||||
this.vaultBrowserStateService.setBrowserVaultItemsComponentState(null),
|
||||
this.stateService.setBrowserSendComponentState(null),
|
||||
this.stateService.setBrowserSendTypeComponentState(null),
|
||||
]);
|
||||
|
|
|
@ -104,6 +104,7 @@ 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 { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
|
||||
import { VaultBrowserStateService } from "../../vault/services/vault-browser-state.service";
|
||||
import { VaultFilterService } from "../../vault/services/vault-filter.service";
|
||||
|
||||
import { DebounceNavigationService } from "./debounce-navigation.service";
|
||||
|
@ -379,6 +380,13 @@ const safeProviders: SafeProvider[] = [
|
|||
provide: OBSERVABLE_DISK_STORAGE,
|
||||
useExisting: AbstractStorageService,
|
||||
}),
|
||||
safeProvider({
|
||||
provide: VaultBrowserStateService,
|
||||
useFactory: (stateProvider: StateProvider) => {
|
||||
return new VaultBrowserStateService(stateProvider);
|
||||
},
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: StateServiceAbstraction,
|
||||
useFactory: (
|
||||
|
|
|
@ -20,7 +20,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
|||
import { BrowserGroupingsComponentState } from "../../../../models/browserGroupingsComponentState";
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
|
||||
import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service";
|
||||
import { VaultBrowserStateService } from "../../../services/vault-browser-state.service";
|
||||
import { VaultFilterService } from "../../../services/vault-filter.service";
|
||||
|
||||
const ComponentId = "VaultComponent";
|
||||
|
@ -84,8 +84,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
|||
private platformUtilsService: PlatformUtilsService,
|
||||
private searchService: SearchService,
|
||||
private location: Location,
|
||||
private browserStateService: BrowserStateService,
|
||||
private vaultFilterService: VaultFilterService,
|
||||
private vaultBrowserStateService: VaultBrowserStateService,
|
||||
) {
|
||||
this.noFolderListSize = 100;
|
||||
}
|
||||
|
@ -95,7 +95,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
|||
this.showLeftHeader = !(
|
||||
BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox()
|
||||
);
|
||||
await this.browserStateService.setBrowserVaultItemsComponentState(null);
|
||||
await this.vaultBrowserStateService.setBrowserVaultItemsComponentState(null);
|
||||
|
||||
this.broadcasterService.subscribe(ComponentId, (message: any) => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
|
@ -120,7 +120,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
|||
const restoredScopeState = await this.restoreState();
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.queryParams.pipe(first()).subscribe(async (params) => {
|
||||
this.state = await this.browserStateService.getBrowserGroupingComponentState();
|
||||
this.state = await this.vaultBrowserStateService.getBrowserGroupingsComponentState();
|
||||
if (this.state?.searchText) {
|
||||
this.searchText = this.state.searchText;
|
||||
} else if (params.searchText) {
|
||||
|
@ -413,11 +413,11 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
|||
collections: this.collections,
|
||||
deletedCount: this.deletedCount,
|
||||
});
|
||||
await this.browserStateService.setBrowserGroupingComponentState(this.state);
|
||||
await this.vaultBrowserStateService.setBrowserGroupingsComponentState(this.state);
|
||||
}
|
||||
|
||||
private async restoreState(): Promise<boolean> {
|
||||
this.state = await this.browserStateService.getBrowserGroupingComponentState();
|
||||
this.state = await this.vaultBrowserStateService.getBrowserGroupingsComponentState();
|
||||
if (this.state == null) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
|||
import { BrowserComponentState } from "../../../../models/browserComponentState";
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
|
||||
import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service";
|
||||
import { VaultBrowserStateService } from "../../../services/vault-browser-state.service";
|
||||
import { VaultFilterService } from "../../../services/vault-filter.service";
|
||||
|
||||
const ComponentId = "VaultItemsComponent";
|
||||
|
@ -59,7 +59,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
|
|||
private ngZone: NgZone,
|
||||
private broadcasterService: BroadcasterService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private stateService: BrowserStateService,
|
||||
private stateService: VaultBrowserStateService,
|
||||
private i18nService: I18nService,
|
||||
private collectionService: CollectionService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
FakeAccountService,
|
||||
mockAccountServiceWith,
|
||||
} from "@bitwarden/common/../spec/fake-account-service";
|
||||
import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
|
||||
import { BrowserComponentState } from "../../models/browserComponentState";
|
||||
import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState";
|
||||
|
||||
import {
|
||||
VAULT_BROWSER_COMPONENT,
|
||||
VAULT_BROWSER_GROUPINGS_COMPONENT,
|
||||
VaultBrowserStateService,
|
||||
} from "./vault-browser-state.service";
|
||||
|
||||
describe("Vault Browser State Service", () => {
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
let accountService: FakeAccountService;
|
||||
let stateService: VaultBrowserStateService;
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
stateService = new VaultBrowserStateService(stateProvider);
|
||||
});
|
||||
|
||||
describe("getBrowserGroupingsComponentState", () => {
|
||||
it("should return a BrowserGroupingsComponentState", async () => {
|
||||
await stateService.setBrowserGroupingsComponentState(new BrowserGroupingsComponentState());
|
||||
|
||||
const actual = await stateService.getBrowserGroupingsComponentState();
|
||||
|
||||
expect(actual).toBeInstanceOf(BrowserGroupingsComponentState);
|
||||
});
|
||||
|
||||
it("should deserialize BrowserGroupingsComponentState", () => {
|
||||
const sut = VAULT_BROWSER_GROUPINGS_COMPONENT;
|
||||
|
||||
const expectedState = {
|
||||
deletedCount: 0,
|
||||
collectionCounts: new Map<string, number>(),
|
||||
folderCounts: new Map<string, number>(),
|
||||
typeCounts: new Map<CipherType, number>(),
|
||||
};
|
||||
|
||||
const result = sut.deserializer(
|
||||
JSON.parse(JSON.stringify(expectedState)) as Jsonify<BrowserGroupingsComponentState>,
|
||||
);
|
||||
|
||||
expect(result).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserVaultItemsComponentState", () => {
|
||||
it("should deserialize BrowserComponentState", () => {
|
||||
const sut = VAULT_BROWSER_COMPONENT;
|
||||
|
||||
const expectedState = {
|
||||
scrollY: 0,
|
||||
searchText: "test",
|
||||
};
|
||||
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedState)));
|
||||
|
||||
expect(result).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it("should return a BrowserComponentState", async () => {
|
||||
const componentState = new BrowserComponentState();
|
||||
componentState.scrollY = 0;
|
||||
componentState.searchText = "test";
|
||||
|
||||
await stateService.setBrowserVaultItemsComponentState(componentState);
|
||||
|
||||
const actual = await stateService.getBrowserVaultItemsComponentState();
|
||||
expect(actual).toStrictEqual(componentState);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,65 @@
|
|||
import { Observable, firstValueFrom } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
ActiveUserState,
|
||||
KeyDefinition,
|
||||
StateProvider,
|
||||
VAULT_BROWSER_MEMORY,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
|
||||
import { BrowserComponentState } from "../../models/browserComponentState";
|
||||
import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState";
|
||||
|
||||
export const VAULT_BROWSER_GROUPINGS_COMPONENT = new KeyDefinition<BrowserGroupingsComponentState>(
|
||||
VAULT_BROWSER_MEMORY,
|
||||
"vault_browser_groupings_component",
|
||||
{
|
||||
deserializer: (obj: Jsonify<BrowserGroupingsComponentState>) =>
|
||||
BrowserGroupingsComponentState.fromJSON(obj),
|
||||
},
|
||||
);
|
||||
|
||||
export const VAULT_BROWSER_COMPONENT = new KeyDefinition<BrowserComponentState>(
|
||||
VAULT_BROWSER_MEMORY,
|
||||
"vault_browser_component",
|
||||
{
|
||||
deserializer: (obj: Jsonify<BrowserComponentState>) => BrowserComponentState.fromJSON(obj),
|
||||
},
|
||||
);
|
||||
|
||||
export class VaultBrowserStateService {
|
||||
vaultBrowserGroupingsComponentState$: Observable<BrowserGroupingsComponentState>;
|
||||
vaultBrowserComponentState$: Observable<BrowserComponentState>;
|
||||
|
||||
private activeUserVaultBrowserGroupingsComponentState: ActiveUserState<BrowserGroupingsComponentState>;
|
||||
private activeUserVaultBrowserComponentState: ActiveUserState<BrowserComponentState>;
|
||||
|
||||
constructor(protected stateProvider: StateProvider) {
|
||||
this.activeUserVaultBrowserGroupingsComponentState = this.stateProvider.getActive(
|
||||
VAULT_BROWSER_GROUPINGS_COMPONENT,
|
||||
);
|
||||
this.activeUserVaultBrowserComponentState =
|
||||
this.stateProvider.getActive(VAULT_BROWSER_COMPONENT);
|
||||
|
||||
this.vaultBrowserGroupingsComponentState$ =
|
||||
this.activeUserVaultBrowserGroupingsComponentState.state$;
|
||||
this.vaultBrowserComponentState$ = this.activeUserVaultBrowserComponentState.state$;
|
||||
}
|
||||
|
||||
async getBrowserGroupingsComponentState(): Promise<BrowserGroupingsComponentState> {
|
||||
return await firstValueFrom(this.vaultBrowserGroupingsComponentState$);
|
||||
}
|
||||
|
||||
async setBrowserGroupingsComponentState(value: BrowserGroupingsComponentState): Promise<void> {
|
||||
await this.activeUserVaultBrowserGroupingsComponentState.update(() => value);
|
||||
}
|
||||
|
||||
async getBrowserVaultItemsComponentState(): Promise<BrowserComponentState> {
|
||||
return await firstValueFrom(this.vaultBrowserComponentState$);
|
||||
}
|
||||
|
||||
async setBrowserVaultItemsComponentState(value: BrowserComponentState): Promise<void> {
|
||||
await this.activeUserVaultBrowserComponentState.update(() => value);
|
||||
}
|
||||
}
|
|
@ -106,6 +106,7 @@ import {
|
|||
PasswordStrengthServiceAbstraction,
|
||||
} from "@bitwarden/common/tools/password-strength";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service";
|
||||
import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
|
@ -194,6 +195,7 @@ export class Main {
|
|||
sendProgram: SendProgram;
|
||||
logService: ConsoleLogService;
|
||||
sendService: SendService;
|
||||
sendStateProvider: SendStateProvider;
|
||||
fileUploadService: FileUploadService;
|
||||
cipherFileUploadService: CipherFileUploadService;
|
||||
keyConnectorService: KeyConnectorService;
|
||||
|
@ -388,11 +390,14 @@ export class Main {
|
|||
|
||||
this.fileUploadService = new FileUploadService(this.logService);
|
||||
|
||||
this.sendStateProvider = new SendStateProvider(this.stateProvider);
|
||||
|
||||
this.sendService = new SendService(
|
||||
this.cryptoService,
|
||||
this.i18nService,
|
||||
this.keyGenerationService,
|
||||
this.stateService,
|
||||
this.sendStateProvider,
|
||||
this.encryptService,
|
||||
);
|
||||
|
||||
this.cipherFileUploadService = new CipherFileUploadService(
|
||||
|
|
|
@ -18,7 +18,7 @@ export class SendRemovePasswordCommand {
|
|||
try {
|
||||
await this.sendApiService.removePassword(id);
|
||||
|
||||
const updatedSend = await this.sendService.get(id);
|
||||
const updatedSend = await firstValueFrom(this.sendService.get$(id));
|
||||
const decSend = await updatedSend.decrypt();
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const webVaultUrl = env.getWebVaultUrl();
|
||||
|
|
|
@ -18,7 +18,6 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory
|
|||
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
|
||||
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
||||
import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service";
|
||||
import { SendData } from "@bitwarden/common/tools/send/models/data/send.data";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
|
||||
import { Account } from "./account";
|
||||
|
@ -71,19 +70,6 @@ export class StateService extends BaseStateService<GlobalState, Account> {
|
|||
return await super.setEncryptedCiphers(value, options);
|
||||
}
|
||||
|
||||
async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> {
|
||||
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
|
||||
return await super.getEncryptedSends(options);
|
||||
}
|
||||
|
||||
async setEncryptedSends(
|
||||
value: { [id: string]: SendData },
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
|
||||
return await super.setEncryptedSends(value, options);
|
||||
}
|
||||
|
||||
override async getLastSync(options?: StorageOptions): Promise<string> {
|
||||
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
|
||||
return await super.getLastSync(options);
|
||||
|
|
|
@ -4956,6 +4956,9 @@
|
|||
"addExistingOrganization": {
|
||||
"message": "Add existing organization"
|
||||
},
|
||||
"addNewOrganization": {
|
||||
"message": "Add new organization"
|
||||
},
|
||||
"myProvider": {
|
||||
"message": "My Provider"
|
||||
},
|
||||
|
@ -7642,5 +7645,38 @@
|
|||
},
|
||||
"items": {
|
||||
"message": "Items"
|
||||
},
|
||||
"assignedSeats": {
|
||||
"message": "Assigned seats"
|
||||
},
|
||||
"assigned": {
|
||||
"message": "Assigned"
|
||||
},
|
||||
"used": {
|
||||
"message": "Used"
|
||||
},
|
||||
"remaining": {
|
||||
"message": "Remaining"
|
||||
},
|
||||
"unlinkOrganization": {
|
||||
"message": "Unlink organization"
|
||||
},
|
||||
"manageSeats": {
|
||||
"message": "MANAGE SEATS"
|
||||
},
|
||||
"manageSeatsDescription": {
|
||||
"message": "Adjustments to seats will be reflected in the next billing cycle."
|
||||
},
|
||||
"unassignedSeatsDescription": {
|
||||
"message": "Unassigned subscription seats"
|
||||
},
|
||||
"purchaseSeatDescription": {
|
||||
"message": "Additional seats purchased"
|
||||
},
|
||||
"assignedSeatCannotUpdate": {
|
||||
"message": "Assigned Seats can not be updated. Please contact your organization owner for assistance."
|
||||
},
|
||||
"subscriptionUpdateFailed": {
|
||||
"message": "Subscription update failed"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
|
@ -13,6 +13,8 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
|||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
@ -50,8 +52,14 @@ export class ClientsComponent implements OnInit {
|
|||
protected actionPromise: Promise<unknown>;
|
||||
private pagedClientsCount = 0;
|
||||
|
||||
protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.EnableConsolidatedBilling,
|
||||
false,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private providerService: ProviderService,
|
||||
private apiService: ApiService,
|
||||
private searchService: SearchService,
|
||||
|
@ -64,20 +72,29 @@ export class ClientsComponent implements OnInit {
|
|||
private organizationService: OrganizationService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.params.subscribe(async (params) => {
|
||||
this.providerId = params.providerId;
|
||||
|
||||
await this.load();
|
||||
const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$);
|
||||
|
||||
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
|
||||
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
|
||||
this.searchText = qParams.search;
|
||||
if (enableConsolidatedBilling) {
|
||||
await this.router.navigate(["../manage-client-organizations"], { relativeTo: this.route });
|
||||
} else {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.params.subscribe(async (params) => {
|
||||
this.providerId = params.providerId;
|
||||
|
||||
await this.load();
|
||||
|
||||
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
|
||||
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
|
||||
this.searchText = qParams.search;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
|
|
|
@ -4,7 +4,11 @@
|
|||
<bit-icon [icon]="logo"></bit-icon>
|
||||
</a>
|
||||
|
||||
<bit-nav-item icon="bwi-bank" [text]="'clients' | i18n" route="clients"></bit-nav-item>
|
||||
<bit-nav-item
|
||||
icon="bwi-bank"
|
||||
[text]="'clients' | i18n"
|
||||
[route]="(enableConsolidatedBilling$ | async) ? 'manage-client-organizations' : 'clients'"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group icon="bwi-sliders" [text]="'manage' | i18n" route="manage" *ngIf="showManageTab">
|
||||
<bit-nav-item
|
||||
[text]="'people' | i18n"
|
||||
|
|
|
@ -37,6 +37,11 @@ export class ProvidersLayoutComponent {
|
|||
false,
|
||||
);
|
||||
|
||||
protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.EnableConsolidatedBilling,
|
||||
false,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private providerService: ProviderService,
|
||||
|
|
|
@ -7,6 +7,8 @@ import { ProvidersComponent } from "@bitwarden/web-vault/app/admin-console/provi
|
|||
import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component";
|
||||
import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
|
||||
|
||||
import { ManageClientOrganizationsComponent } from "../../billing/providers/clients/manage-client-organizations.component";
|
||||
|
||||
import { ClientsComponent } from "./clients/clients.component";
|
||||
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
||||
import { ProviderPermissionsGuard } from "./guards/provider-permissions.guard";
|
||||
|
@ -64,6 +66,11 @@ const routes: Routes = [
|
|||
{ path: "", pathMatch: "full", redirectTo: "clients" },
|
||||
{ path: "clients/create", component: CreateOrganizationComponent },
|
||||
{ path: "clients", component: ClientsComponent, data: { titleId: "clients" } },
|
||||
{
|
||||
path: "manage-client-organizations",
|
||||
component: ManageClientOrganizationsComponent,
|
||||
data: { titleId: "manage-client-organizations" },
|
||||
},
|
||||
{
|
||||
path: "manage",
|
||||
children: [
|
||||
|
|
|
@ -8,6 +8,9 @@ import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
|
|||
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
|
||||
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
|
||||
|
||||
import { ManageClientOrganizationSubscriptionComponent } from "../../billing/providers/clients/manage-client-organization-subscription.component";
|
||||
import { ManageClientOrganizationsComponent } from "../../billing/providers/clients/manage-client-organizations.component";
|
||||
|
||||
import { AddOrganizationComponent } from "./clients/add-organization.component";
|
||||
import { ClientsComponent } from "./clients/clients.component";
|
||||
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
||||
|
@ -50,6 +53,8 @@ import { SetupComponent } from "./setup/setup.component";
|
|||
SetupComponent,
|
||||
SetupProviderComponent,
|
||||
UserAddEditComponent,
|
||||
ManageClientOrganizationsComponent,
|
||||
ManageClientOrganizationSubscriptionComponent,
|
||||
],
|
||||
providers: [WebProviderService, ProviderPermissionsGuard],
|
||||
})
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
<bit-dialog dialogSize="large" [loading]="loading">
|
||||
<span bitDialogTitle>
|
||||
{{ "manageSeats" | i18n }}
|
||||
<small class="tw-text-muted" *ngIf="clientName">{{ clientName }}</small>
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<p>
|
||||
{{ "manageSeatsDescription" | i18n }}
|
||||
</p>
|
||||
<bit-form-field>
|
||||
<bit-label>
|
||||
{{ "assignedSeats" | i18n }}
|
||||
</bit-label>
|
||||
<input
|
||||
id="assignedSeats"
|
||||
type="number"
|
||||
appAutoFocus
|
||||
bitInput
|
||||
required
|
||||
[(ngModel)]="assignedSeats"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<ng-container *ngIf="remainingOpenSeats > 0">
|
||||
<p>
|
||||
<small class="tw-text-muted">{{ unassignedSeats }}</small>
|
||||
<small class="tw-text-muted">{{ "unassignedSeatsDescription" | i18n }}</small>
|
||||
</p>
|
||||
<p>
|
||||
<small class="tw-text-muted">{{ AdditionalSeatPurchased }}</small>
|
||||
<small class="tw-text-muted">{{ "purchaseSeatDescription" | i18n }}</small>
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
type="submit"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
bitFormButton
|
||||
(click)="updateSubscription(assignedSeats)"
|
||||
>
|
||||
<i class="bwi bwi-refresh bwi-fw" aria-hidden="true"></i>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" bitDialogClose>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
|
@ -0,0 +1,115 @@
|
|||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { ProviderSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/provider-subscription-update.request";
|
||||
import { Plans } from "@bitwarden/common/billing/models/response/provider-subscription-response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
type ManageClientOrganizationDialogParams = {
|
||||
organization: ProviderOrganizationOrganizationDetailsResponse;
|
||||
};
|
||||
|
||||
@Component({
|
||||
templateUrl: "manage-client-organization-subscription.component.html",
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class ManageClientOrganizationSubscriptionComponent implements OnInit {
|
||||
loading = true;
|
||||
providerOrganizationId: string;
|
||||
providerId: string;
|
||||
|
||||
clientName: string;
|
||||
assignedSeats: number;
|
||||
unassignedSeats: number;
|
||||
planName: string;
|
||||
AdditionalSeatPurchased: number;
|
||||
remainingOpenSeats: number;
|
||||
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) protected data: ManageClientOrganizationDialogParams,
|
||||
private billingApiService: BillingApiService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {
|
||||
this.providerOrganizationId = data.organization.id;
|
||||
this.providerId = data.organization.providerId;
|
||||
this.clientName = data.organization.organizationName;
|
||||
this.assignedSeats = data.organization.seats;
|
||||
this.planName = data.organization.plan;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const response = await this.billingApiService.getProviderClientSubscriptions(this.providerId);
|
||||
this.AdditionalSeatPurchased = this.getPurchasedSeatsByPlan(this.planName, response.plans);
|
||||
const seatMinimum = this.getProviderSeatMinimumByPlan(this.planName, response.plans);
|
||||
const assignedByPlan = this.getAssignedByPlan(this.planName, response.plans);
|
||||
this.remainingOpenSeats = seatMinimum - assignedByPlan;
|
||||
this.unassignedSeats = Math.abs(this.remainingOpenSeats);
|
||||
} catch (error) {
|
||||
this.remainingOpenSeats = 0;
|
||||
this.AdditionalSeatPurchased = 0;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async updateSubscription(assignedSeats: number) {
|
||||
this.loading = true;
|
||||
if (!assignedSeats) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("assignedSeatCannotUpdate"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new ProviderSubscriptionUpdateRequest();
|
||||
request.assignedSeats = assignedSeats;
|
||||
|
||||
await this.billingApiService.putProviderClientSubscriptions(
|
||||
this.providerId,
|
||||
this.providerOrganizationId,
|
||||
request,
|
||||
);
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated"));
|
||||
this.loading = false;
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
getPurchasedSeatsByPlan(planName: string, plans: Plans[]): number {
|
||||
const plan = plans.find((plan) => plan.planName === planName);
|
||||
if (plan) {
|
||||
return plan.purchasedSeats;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
getAssignedByPlan(planName: string, plans: Plans[]): number {
|
||||
const plan = plans.find((plan) => plan.planName === planName);
|
||||
if (plan) {
|
||||
return plan.assignedSeats;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
getProviderSeatMinimumByPlan(planName: string, plans: Plans[]) {
|
||||
const plan = plans.find((plan) => plan.planName === planName);
|
||||
if (plan) {
|
||||
return plan.seatMinimum;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, data: ManageClientOrganizationDialogParams) {
|
||||
return dialogService.open(ManageClientOrganizationSubscriptionComponent, { data });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
<app-header>
|
||||
<bit-search [placeholder]="'search' | i18n" [(ngModel)]="searchText"></bit-search>
|
||||
<a bitButton routerLink="create" *ngIf="manageOrganizations" buttonType="primary">
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "addNewOrganization" | i18n }}
|
||||
</a>
|
||||
</app-header>
|
||||
|
||||
<ng-container *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="!loading && (clients | search: searchText : 'organizationName' : 'id') as searchedClients"
|
||||
>
|
||||
<p *ngIf="!searchedClients.length">{{ "noClientsInList" | i18n }}</p>
|
||||
<ng-container *ngIf="searchedClients.length">
|
||||
<bit-table
|
||||
*ngIf="searchedClients?.length >= 1"
|
||||
[dataSource]="dataSource"
|
||||
class="table table-hover table-list"
|
||||
infiniteScroll
|
||||
[infiniteScrollDistance]="1"
|
||||
[infiniteScrollDisabled]="!isPaging()"
|
||||
(scrolled)="loadMore()"
|
||||
>
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th colspan="2" bitCell bitSortable="organizationName" default>{{ "client" | i18n }}</th>
|
||||
<th bitCell bitSortable="seats">{{ "assigned" | i18n }}</th>
|
||||
<th bitCell bitSortable="userCount">{{ "used" | i18n }}</th>
|
||||
<th bitCell bitSortable="userCount">{{ "remaining" | i18n }}</th>
|
||||
<th bitCell bitSortable="plan">{{ "billingPlan" | i18n }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body let-rows$>
|
||||
<tr bitRow *ngFor="let client of rows$ | async">
|
||||
<td bitCell width="30">
|
||||
<bit-avatar [text]="client.organizationName" [id]="client.id" size="small"></bit-avatar>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-break-all">
|
||||
<a bitLink [routerLink]="['/organizations', client.organizationId]">{{
|
||||
client.organizationName
|
||||
}}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td bitCell class="tw-whitespace-nowrap">
|
||||
<span>{{ client.seats }}</span>
|
||||
</td>
|
||||
<td bitCell class="tw-whitespace-nowrap">
|
||||
<span>{{ client.userCount }}</span>
|
||||
</td>
|
||||
<td bitCell class="tw-whitespace-nowrap">
|
||||
<span>{{ client.seats - client.userCount }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ client.plan }}</span>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
<bit-menu #rowMenu>
|
||||
<button type="button" bitMenuItem (click)="manageSubscription(client)">
|
||||
<i aria-hidden="true" class="bwi bwi-question-circle"></i>
|
||||
{{ "manageSubscription" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="remove(client)">
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "unlinkOrganization" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</ng-container>
|
||||
</ng-container>
|
|
@ -0,0 +1,160 @@
|
|||
import { SelectionModel } from "@angular/cdk/collections";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { DialogService, TableDataSource } from "@bitwarden/components";
|
||||
|
||||
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||
|
||||
import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-organization-subscription.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "manage-client-organizations.component.html",
|
||||
})
|
||||
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class ManageClientOrganizationsComponent implements OnInit {
|
||||
providerId: string;
|
||||
loading = true;
|
||||
manageOrganizations = false;
|
||||
|
||||
set searchText(search: string) {
|
||||
this.selection.clear();
|
||||
this.dataSource.filter = search;
|
||||
}
|
||||
|
||||
clients: ProviderOrganizationOrganizationDetailsResponse[];
|
||||
pagedClients: ProviderOrganizationOrganizationDetailsResponse[];
|
||||
|
||||
protected didScroll = false;
|
||||
protected pageSize = 100;
|
||||
protected actionPromise: Promise<unknown>;
|
||||
private pagedClientsCount = 0;
|
||||
selection = new SelectionModel<string>(true, []);
|
||||
protected dataSource = new TableDataSource<ProviderOrganizationOrganizationDetailsResponse>();
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private providerService: ProviderService,
|
||||
private apiService: ApiService,
|
||||
private searchService: SearchService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private validationService: ValidationService,
|
||||
private webProviderService: WebProviderService,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.params.subscribe(async (params) => {
|
||||
this.providerId = params.providerId;
|
||||
|
||||
await this.load();
|
||||
|
||||
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
|
||||
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
|
||||
this.searchText = qParams.search;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async load() {
|
||||
const response = await this.apiService.getProviderClients(this.providerId);
|
||||
this.clients = response.data != null && response.data.length > 0 ? response.data : [];
|
||||
this.dataSource.data = this.clients;
|
||||
this.manageOrganizations =
|
||||
(await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin;
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
isPaging() {
|
||||
const searching = this.isSearching();
|
||||
if (searching && this.didScroll) {
|
||||
// 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.resetPaging();
|
||||
}
|
||||
return !searching && this.clients && this.clients.length > this.pageSize;
|
||||
}
|
||||
|
||||
isSearching() {
|
||||
return this.searchService.isSearchable(this.searchText);
|
||||
}
|
||||
|
||||
async resetPaging() {
|
||||
this.pagedClients = [];
|
||||
this.loadMore();
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
if (!this.clients || this.clients.length <= this.pageSize) {
|
||||
return;
|
||||
}
|
||||
const pagedLength = this.pagedClients.length;
|
||||
let pagedSize = this.pageSize;
|
||||
if (pagedLength === 0 && this.pagedClientsCount > this.pageSize) {
|
||||
pagedSize = this.pagedClientsCount;
|
||||
}
|
||||
if (this.clients.length > pagedLength) {
|
||||
this.pagedClients = this.pagedClients.concat(
|
||||
this.clients.slice(pagedLength, pagedLength + pagedSize),
|
||||
);
|
||||
}
|
||||
this.pagedClientsCount = this.pagedClients.length;
|
||||
this.didScroll = this.pagedClients.length > this.pageSize;
|
||||
}
|
||||
|
||||
async manageSubscription(organization: ProviderOrganizationOrganizationDetailsResponse) {
|
||||
if (organization == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = ManageClientOrganizationSubscriptionComponent.open(this.dialogService, {
|
||||
organization: organization,
|
||||
});
|
||||
|
||||
await firstValueFrom(dialogRef.closed);
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async remove(organization: ProviderOrganizationOrganizationDetailsResponse) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: organization.organizationName,
|
||||
content: { key: "detachOrganizationConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.actionPromise = this.webProviderService.detachOrganization(
|
||||
this.providerId,
|
||||
organization.id,
|
||||
);
|
||||
try {
|
||||
await this.actionPromise;
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t("detachedOrganization", organization.organizationName),
|
||||
);
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
this.actionPromise = null;
|
||||
}
|
||||
}
|
|
@ -192,6 +192,8 @@ import {
|
|||
} from "@bitwarden/common/tools/password-strength";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service";
|
||||
import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendStateProvider as SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider";
|
||||
import { SendStateProvider as SendStateProviderAbstraction } from "@bitwarden/common/tools/send/services/send-state.provider.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service";
|
||||
import {
|
||||
InternalSendService,
|
||||
|
@ -569,9 +571,15 @@ const safeProviders: SafeProvider[] = [
|
|||
CryptoServiceAbstraction,
|
||||
I18nServiceAbstraction,
|
||||
KeyGenerationServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
SendStateProviderAbstraction,
|
||||
EncryptService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SendStateProviderAbstraction,
|
||||
useClass: SendStateProvider,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SendApiServiceAbstraction,
|
||||
useClass: SendApiService,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Subject, firstValueFrom, takeUntil } from "rxjs";
|
||||
import { Subject, firstValueFrom, mergeMap, takeUntil } from "rxjs";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
|
@ -77,9 +77,15 @@ export class SendComponent implements OnInit, OnDestroy {
|
|||
|
||||
async load(filter: (send: SendView) => boolean = null) {
|
||||
this.loading = true;
|
||||
this.sendService.sendViews$.pipe(takeUntil(this.destroy$)).subscribe((sends) => {
|
||||
this.sends = sends;
|
||||
});
|
||||
this.sendService.sendViews$
|
||||
.pipe(
|
||||
mergeMap(async (sends) => {
|
||||
this.sends = sends;
|
||||
await this.search(null);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
if (this.onSuccessfulLoad != null) {
|
||||
await this.onSuccessfulLoad();
|
||||
} else {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended";
|
|||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
|
@ -30,6 +31,22 @@ describe("AuthRequestService", () => {
|
|||
mockPrivateKey = new Uint8Array(64);
|
||||
});
|
||||
|
||||
describe("authRequestPushNotification$", () => {
|
||||
it("should emit when sendAuthRequestPushNotification is called", () => {
|
||||
const notification = {
|
||||
id: "PUSH_NOTIFICATION",
|
||||
userId: "USER_ID",
|
||||
} as AuthRequestPushNotification;
|
||||
|
||||
const spy = jest.fn();
|
||||
sut.authRequestPushNotification$.subscribe(spy);
|
||||
|
||||
sut.sendAuthRequestPushNotification(notification);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith("PUSH_NOTIFICATION");
|
||||
});
|
||||
});
|
||||
|
||||
describe("approveOrDenyAuthRequest", () => {
|
||||
beforeEach(() => {
|
||||
cryptoService.rsaEncrypt.mockResolvedValue({
|
||||
|
|
|
@ -22,7 +22,9 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
|
|||
private cryptoService: CryptoService,
|
||||
private apiService: ApiService,
|
||||
private stateService: StateService,
|
||||
) {}
|
||||
) {
|
||||
this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable();
|
||||
}
|
||||
|
||||
async approveOrDenyAuthRequest(
|
||||
approve: boolean,
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
||||
import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response";
|
||||
import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request";
|
||||
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
|
||||
|
||||
export abstract class BillingApiServiceAbstraction {
|
||||
cancelOrganizationSubscription: (
|
||||
|
@ -8,4 +10,10 @@ export abstract class BillingApiServiceAbstraction {
|
|||
) => Promise<void>;
|
||||
cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise<void>;
|
||||
getBillingStatus: (id: string) => Promise<OrganizationBillingStatusResponse>;
|
||||
getProviderClientSubscriptions: (providerId: string) => Promise<ProviderSubscriptionResponse>;
|
||||
putProviderClientSubscriptions: (
|
||||
providerId: string,
|
||||
organizationId: string,
|
||||
request: ProviderSubscriptionUpdateRequest,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export class ProviderSubscriptionUpdateRequest {
|
||||
assignedSeats: number;
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class ProviderSubscriptionResponse extends BaseResponse {
|
||||
status: string;
|
||||
currentPeriodEndDate: Date;
|
||||
discountPercentage?: number | null;
|
||||
plans: Plans[] = [];
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.status = this.getResponseProperty("status");
|
||||
this.currentPeriodEndDate = new Date(this.getResponseProperty("currentPeriodEndDate"));
|
||||
this.discountPercentage = this.getResponseProperty("discountPercentage");
|
||||
const plans = this.getResponseProperty("plans");
|
||||
if (plans != null) {
|
||||
this.plans = plans.map((i: any) => new Plans(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Plans extends BaseResponse {
|
||||
planName: string;
|
||||
seatMinimum: number;
|
||||
assignedSeats: number;
|
||||
purchasedSeats: number;
|
||||
cost: number;
|
||||
cadence: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.planName = this.getResponseProperty("PlanName");
|
||||
this.seatMinimum = this.getResponseProperty("SeatMinimum");
|
||||
this.assignedSeats = this.getResponseProperty("AssignedSeats");
|
||||
this.purchasedSeats = this.getResponseProperty("PurchasedSeats");
|
||||
this.cost = this.getResponseProperty("Cost");
|
||||
this.cadence = this.getResponseProperty("Cadence");
|
||||
}
|
||||
}
|
|
@ -2,6 +2,8 @@ import { ApiService } from "../../abstractions/api.service";
|
|||
import { BillingApiServiceAbstraction } from "../../billing/abstractions/billilng-api.service.abstraction";
|
||||
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
||||
import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response";
|
||||
import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request";
|
||||
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
|
||||
|
||||
export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
@ -34,4 +36,29 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
|||
|
||||
return new OrganizationBillingStatusResponse(r);
|
||||
}
|
||||
|
||||
async getProviderClientSubscriptions(providerId: string): Promise<ProviderSubscriptionResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/providers/" + providerId + "/billing/subscription",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new ProviderSubscriptionResponse(r);
|
||||
}
|
||||
|
||||
async putProviderClientSubscriptions(
|
||||
providerId: string,
|
||||
organizationId: string,
|
||||
request: ProviderSubscriptionUpdateRequest,
|
||||
): Promise<any> {
|
||||
return await this.apiService.send(
|
||||
"PUT",
|
||||
"/providers/" + providerId + "/organizations/" + organizationId,
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ export enum FeatureFlag {
|
|||
KeyRotationImprovements = "key-rotation-improvements",
|
||||
FlexibleCollectionsMigration = "flexible-collections-migration",
|
||||
ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners",
|
||||
EnableConsolidatedBilling = "enable-consolidated-billing",
|
||||
}
|
||||
|
||||
// Replace this with a type safe lookup of the feature flag values in PM-2282
|
||||
|
|
|
@ -7,8 +7,6 @@ import { BiometricKey } from "../../auth/types/biometric-key";
|
|||
import { GeneratorOptions } from "../../tools/generator/generator-options";
|
||||
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
|
||||
import { UsernameGeneratorOptions } from "../../tools/generator/username";
|
||||
import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { CipherData } from "../../vault/models/data/cipher.data";
|
||||
|
@ -151,14 +149,6 @@ export abstract class StateService<T extends Account = Account> {
|
|||
* @deprecated For migration purposes only, use setDecryptedUserKeyPin instead
|
||||
*/
|
||||
setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
getDecryptedSends: (options?: StorageOptions) => Promise<SendView[]>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
|
||||
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
|
||||
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getAdminAuthRequest: (options?: StorageOptions) => Promise<AdminAuthRequestStorable | null>;
|
||||
|
@ -197,14 +187,6 @@ export abstract class StateService<T extends Account = Account> {
|
|||
* @deprecated For migration purposes only, use setEncryptedUserKeyPin instead
|
||||
*/
|
||||
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
getEncryptedSends: (options?: StorageOptions) => Promise<{ [id: string]: SendData }>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise<void>;
|
||||
getEverBeenUnlocked: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getForceSetPasswordReason: (options?: StorageOptions) => Promise<ForceSetPasswordReason>;
|
||||
|
|
|
@ -9,8 +9,6 @@ import {
|
|||
PasswordGeneratorOptions,
|
||||
} from "../../../tools/generator/password";
|
||||
import { UsernameGeneratorOptions } from "../../../tools/generator/username/username-generation-options";
|
||||
import { SendData } from "../../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../../tools/send/models/view/send.view";
|
||||
import { DeepJsonify } from "../../../types/deep-jsonify";
|
||||
import { MasterKey } from "../../../types/key";
|
||||
import { CipherData } from "../../../vault/models/data/cipher.data";
|
||||
|
@ -71,7 +69,6 @@ export class AccountData {
|
|||
CipherView
|
||||
>();
|
||||
localData?: any;
|
||||
sends?: DataEncryptionPair<SendData, SendView> = new DataEncryptionPair<SendData, SendView>();
|
||||
passwordGenerationHistory?: EncryptionPair<
|
||||
GeneratedPasswordHistory[],
|
||||
GeneratedPasswordHistory[]
|
||||
|
|
|
@ -11,8 +11,6 @@ import { BiometricKey } from "../../auth/types/biometric-key";
|
|||
import { GeneratorOptions } from "../../tools/generator/generator-options";
|
||||
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
|
||||
import { UsernameGeneratorOptions } from "../../tools/generator/username";
|
||||
import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { CipherData } from "../../vault/models/data/cipher.data";
|
||||
|
@ -614,24 +612,6 @@ export class StateService<
|
|||
);
|
||||
}
|
||||
|
||||
@withPrototypeForArrayMembers(SendView)
|
||||
async getDecryptedSends(options?: StorageOptions): Promise<SendView[]> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.data?.sends?.decrypted;
|
||||
}
|
||||
|
||||
async setDecryptedSends(value: SendView[], options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
account.data.sends.decrypted = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string> {
|
||||
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
|
||||
if (options?.userId == null) {
|
||||
|
@ -825,27 +805,6 @@ export class StateService<
|
|||
);
|
||||
}
|
||||
|
||||
@withPrototypeForObjectValues(SendData)
|
||||
async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
|
||||
)?.data?.sends.encrypted;
|
||||
}
|
||||
|
||||
async setEncryptedSends(
|
||||
value: { [id: string]: SendData },
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()),
|
||||
);
|
||||
account.data.sends.encrypted = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getEverBeenUnlocked(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())))
|
||||
|
|
|
@ -103,6 +103,10 @@ 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");
|
||||
export const SEND_DISK = new StateDefinition("encryptedSend", "disk", {
|
||||
web: "memory",
|
||||
});
|
||||
export const SEND_MEMORY = new StateDefinition("decryptedSend", "memory");
|
||||
|
||||
// Vault
|
||||
|
||||
|
@ -119,3 +123,4 @@ export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", {
|
|||
export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory");
|
||||
|
|
|
@ -50,6 +50,7 @@ import { KeyConnectorMigrator } from "./migrations/50-move-key-connector-to-stat
|
|||
import { RememberedEmailMigrator } from "./migrations/51-move-remembered-email-to-state-providers";
|
||||
import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version";
|
||||
import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-crypto-svc-to-state-providers";
|
||||
import { SendMigrator } from "./migrations/54-move-encrypted-sends";
|
||||
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
||||
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
|
||||
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
|
||||
|
@ -57,7 +58,8 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
|
|||
import { MinVersionMigrator } from "./migrations/min-version";
|
||||
|
||||
export const MIN_VERSION = 3;
|
||||
export const CURRENT_VERSION = 53;
|
||||
export const CURRENT_VERSION = 54;
|
||||
|
||||
export type MinVersion = typeof MIN_VERSION;
|
||||
|
||||
export function createMigrationBuilder() {
|
||||
|
@ -112,7 +114,8 @@ export function createMigrationBuilder() {
|
|||
.with(KeyConnectorMigrator, 49, 50)
|
||||
.with(RememberedEmailMigrator, 50, 51)
|
||||
.with(DeleteInstalledVersion, 51, 52)
|
||||
.with(DeviceTrustCryptoServiceStateProviderMigrator, 52, CURRENT_VERSION);
|
||||
.with(DeviceTrustCryptoServiceStateProviderMigrator, 52, 53)
|
||||
.with(SendMigrator, 53, 54);
|
||||
}
|
||||
|
||||
export async function currentVersion(
|
||||
|
|
|
@ -0,0 +1,236 @@
|
|||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { SendMigrator } from "./54-move-encrypted-sends";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
data: {
|
||||
sends: {
|
||||
encrypted: {
|
||||
"2ebadc23-e101-471b-bf2d-b125015337a0": {
|
||||
id: "2ebadc23-e101-471b-bf2d-b125015337a0",
|
||||
accessId: "I9y6LgHhG0e_LbElAVM3oA",
|
||||
deletionDate: "2024-03-07T20:35:03Z",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=",
|
||||
name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=",
|
||||
text: {
|
||||
hidden: false,
|
||||
text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=",
|
||||
},
|
||||
type: 0,
|
||||
},
|
||||
"3b31c20d-b783-4912-9170-b12501555398": {
|
||||
id: "3b31c20d-b783-4912-9170-b12501555398",
|
||||
accessId: "DcIxO4O3EkmRcLElAVVTmA",
|
||||
deletionDate: "2024-03-07T20:42:43Z",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=",
|
||||
name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=",
|
||||
text: {
|
||||
hidden: false,
|
||||
text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=",
|
||||
},
|
||||
type: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
data: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_send_sends": {
|
||||
"2ebadc23-e101-471b-bf2d-b125015337a0": {
|
||||
id: "2ebadc23-e101-471b-bf2d-b125015337a0",
|
||||
accessId: "I9y6LgHhG0e_LbElAVM3oA",
|
||||
deletionDate: "2024-03-07T20:35:03Z",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=",
|
||||
name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=",
|
||||
text: {
|
||||
hidden: false,
|
||||
text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=",
|
||||
},
|
||||
type: 0,
|
||||
},
|
||||
"3b31c20d-b783-4912-9170-b12501555398": {
|
||||
id: "3b31c20d-b783-4912-9170-b12501555398",
|
||||
accessId: "DcIxO4O3EkmRcLElAVVTmA",
|
||||
deletionDate: "2024-03-07T20:42:43Z",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=",
|
||||
name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=",
|
||||
text: {
|
||||
hidden: false,
|
||||
text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=",
|
||||
},
|
||||
type: 0,
|
||||
},
|
||||
},
|
||||
"user_user-2_send_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("SendMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: SendMigrator;
|
||||
const keyDefinitionLike = {
|
||||
stateDefinition: {
|
||||
name: "send",
|
||||
},
|
||||
key: "sends",
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 53);
|
||||
sut = new SendMigrator(53, 54);
|
||||
});
|
||||
|
||||
it("should remove encrypted sends from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
data: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set encrypted sends for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
|
||||
"2ebadc23-e101-471b-bf2d-b125015337a0": {
|
||||
id: "2ebadc23-e101-471b-bf2d-b125015337a0",
|
||||
accessId: "I9y6LgHhG0e_LbElAVM3oA",
|
||||
deletionDate: "2024-03-07T20:35:03Z",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=",
|
||||
name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=",
|
||||
text: {
|
||||
hidden: false,
|
||||
text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=",
|
||||
},
|
||||
type: 0,
|
||||
},
|
||||
"3b31c20d-b783-4912-9170-b12501555398": {
|
||||
id: "3b31c20d-b783-4912-9170-b12501555398",
|
||||
accessId: "DcIxO4O3EkmRcLElAVVTmA",
|
||||
deletionDate: "2024-03-07T20:42:43Z",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=",
|
||||
name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=",
|
||||
text: {
|
||||
hidden: false,
|
||||
text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=",
|
||||
},
|
||||
type: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 54);
|
||||
sut = new SendMigrator(53, 54);
|
||||
});
|
||||
|
||||
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 encrypted send values back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalled();
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
data: {
|
||||
sends: {
|
||||
encrypted: {
|
||||
"2ebadc23-e101-471b-bf2d-b125015337a0": {
|
||||
id: "2ebadc23-e101-471b-bf2d-b125015337a0",
|
||||
accessId: "I9y6LgHhG0e_LbElAVM3oA",
|
||||
deletionDate: "2024-03-07T20:35:03Z",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=",
|
||||
name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=",
|
||||
text: {
|
||||
hidden: false,
|
||||
text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=",
|
||||
},
|
||||
type: 0,
|
||||
},
|
||||
"3b31c20d-b783-4912-9170-b12501555398": {
|
||||
id: "3b31c20d-b783-4912-9170-b12501555398",
|
||||
accessId: "DcIxO4O3EkmRcLElAVVTmA",
|
||||
deletionDate: "2024-03-07T20:42:43Z",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=",
|
||||
name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=",
|
||||
text: {
|
||||
hidden: false,
|
||||
text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=",
|
||||
},
|
||||
type: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
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());
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,67 @@
|
|||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
export enum SendType {
|
||||
Text = 0,
|
||||
File = 1,
|
||||
}
|
||||
|
||||
type SendData = {
|
||||
id: string;
|
||||
accessId: string;
|
||||
};
|
||||
|
||||
type ExpectedSendState = {
|
||||
data?: {
|
||||
sends?: {
|
||||
encrypted?: Record<string, SendData>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const ENCRYPTED_SENDS: KeyDefinitionLike = {
|
||||
stateDefinition: {
|
||||
name: "send",
|
||||
},
|
||||
key: "sends",
|
||||
};
|
||||
|
||||
/**
|
||||
* Only encrypted sends are stored on disk. Only the encrypted items need to be
|
||||
* migrated from the previous sends state data.
|
||||
*/
|
||||
export class SendMigrator extends Migrator<53, 54> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedSendState>();
|
||||
|
||||
async function migrateAccount(userId: string, account: ExpectedSendState): Promise<void> {
|
||||
const value = account?.data?.sends?.encrypted;
|
||||
if (value != null) {
|
||||
await helper.setToUser(userId, ENCRYPTED_SENDS, value);
|
||||
delete account.data.sends;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedSendState>();
|
||||
|
||||
async function rollbackAccount(userId: string, account: ExpectedSendState): Promise<void> {
|
||||
const value = await helper.getFromUser(userId, ENCRYPTED_SENDS);
|
||||
if (account) {
|
||||
account.data = Object.assign(account.data ?? {}, {
|
||||
sends: {
|
||||
encrypted: value,
|
||||
},
|
||||
});
|
||||
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, ENCRYPTED_SENDS, null);
|
||||
}
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
}
|
21
libs/common/src/tools/send/services/key-definitions.spec.ts
Normal file
21
libs/common/src/tools/send/services/key-definitions.spec.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { SEND_USER_ENCRYPTED, SEND_USER_DECRYPTED } from "./key-definitions";
|
||||
import { testSendData, testSendViewData } from "./test-data/send-tests.data";
|
||||
|
||||
describe("Key definitions", () => {
|
||||
describe("SEND_USER_ENCRYPTED", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const result = SEND_USER_ENCRYPTED.deserializer(
|
||||
JSON.parse(JSON.stringify(testSendData("1", "Test Send Data"))),
|
||||
);
|
||||
expect(result).toEqual(testSendData("1", "Test Send Data"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("SEND_USER_DECRYPTED", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const sendViews = [testSendViewData("1", "Test Send View")];
|
||||
const result = SEND_USER_DECRYPTED.deserializer(JSON.parse(JSON.stringify(sendViews)));
|
||||
expect(result).toEqual(sendViews);
|
||||
});
|
||||
});
|
||||
});
|
13
libs/common/src/tools/send/services/key-definitions.ts
Normal file
13
libs/common/src/tools/send/services/key-definitions.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { KeyDefinition, SEND_DISK, SEND_MEMORY } from "../../../platform/state";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
|
||||
/** Encrypted send state stored on disk */
|
||||
export const SEND_USER_ENCRYPTED = KeyDefinition.record<SendData>(SEND_DISK, "sendUserEncrypted", {
|
||||
deserializer: (obj: SendData) => obj,
|
||||
});
|
||||
|
||||
/** Decrypted send state stored in memory */
|
||||
export const SEND_USER_DECRYPTED = new KeyDefinition<SendView[]>(SEND_MEMORY, "sendUserDecrypted", {
|
||||
deserializer: (obj) => obj,
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
import { Observable } from "rxjs";
|
||||
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
|
||||
export abstract class SendStateProvider {
|
||||
encryptedState$: Observable<Record<string, SendData>>;
|
||||
decryptedState$: Observable<SendView[]>;
|
||||
|
||||
getEncryptedSends: () => Promise<{ [id: string]: SendData }>;
|
||||
|
||||
setEncryptedSends: (value: { [id: string]: SendData }) => Promise<void>;
|
||||
|
||||
getDecryptedSends: () => Promise<SendView[]>;
|
||||
|
||||
setDecryptedSends: (value: SendView[]) => Promise<void>;
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
FakeAccountService,
|
||||
FakeStateProvider,
|
||||
awaitAsync,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { UserId } from "../../../types/guid";
|
||||
|
||||
import { SendStateProvider } from "./send-state.provider";
|
||||
import { testSendData, testSendViewData } from "./test-data/send-tests.data";
|
||||
|
||||
describe("Send State Provider", () => {
|
||||
let stateProvider: FakeStateProvider;
|
||||
let accountService: FakeAccountService;
|
||||
let sendStateProvider: SendStateProvider;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
sendStateProvider = new SendStateProvider(stateProvider);
|
||||
});
|
||||
|
||||
describe("Encrypted Sends", () => {
|
||||
it("should return SendData", async () => {
|
||||
const sendData = { "1": testSendData("1", "Test Send Data") };
|
||||
await sendStateProvider.setEncryptedSends(sendData);
|
||||
await awaitAsync();
|
||||
|
||||
const actual = await sendStateProvider.getEncryptedSends();
|
||||
expect(actual).toStrictEqual(sendData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Decrypted Sends", () => {
|
||||
it("should return SendView", async () => {
|
||||
const state = [testSendViewData("1", "Test")];
|
||||
await sendStateProvider.setDecryptedSends(state);
|
||||
await awaitAsync();
|
||||
|
||||
const actual = await sendStateProvider.getDecryptedSends();
|
||||
expect(actual).toStrictEqual(state);
|
||||
});
|
||||
});
|
||||
});
|
47
libs/common/src/tools/send/services/send-state.provider.ts
Normal file
47
libs/common/src/tools/send/services/send-state.provider.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { Observable, firstValueFrom } from "rxjs";
|
||||
|
||||
import { ActiveUserState, StateProvider } from "../../../platform/state";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
|
||||
import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions";
|
||||
import { SendStateProvider as SendStateProviderAbstraction } from "./send-state.provider.abstraction";
|
||||
|
||||
/** State provider for sends */
|
||||
export class SendStateProvider implements SendStateProviderAbstraction {
|
||||
/** Observable for the encrypted sends for an active user */
|
||||
encryptedState$: Observable<Record<string, SendData>>;
|
||||
/** Observable with the decrypted sends for an active user */
|
||||
decryptedState$: Observable<SendView[]>;
|
||||
|
||||
private activeUserEncryptedState: ActiveUserState<Record<string, SendData>>;
|
||||
private activeUserDecryptedState: ActiveUserState<SendView[]>;
|
||||
|
||||
constructor(protected stateProvider: StateProvider) {
|
||||
this.activeUserEncryptedState = this.stateProvider.getActive(SEND_USER_ENCRYPTED);
|
||||
this.encryptedState$ = this.activeUserEncryptedState.state$;
|
||||
|
||||
this.activeUserDecryptedState = this.stateProvider.getActive(SEND_USER_DECRYPTED);
|
||||
this.decryptedState$ = this.activeUserDecryptedState.state$;
|
||||
}
|
||||
|
||||
/** Gets the encrypted sends from state for an active user */
|
||||
async getEncryptedSends(): Promise<{ [id: string]: SendData }> {
|
||||
return await firstValueFrom(this.encryptedState$);
|
||||
}
|
||||
|
||||
/** Sets the encrypted send state for an active user */
|
||||
async setEncryptedSends(value: { [id: string]: SendData }): Promise<void> {
|
||||
await this.activeUserEncryptedState.update(() => value);
|
||||
}
|
||||
|
||||
/** Gets the decrypted sends from state for the active user */
|
||||
async getDecryptedSends(): Promise<SendView[]> {
|
||||
return await firstValueFrom(this.decryptedState$);
|
||||
}
|
||||
|
||||
/** Sets the decrypted send state for an active user */
|
||||
async setDecryptedSends(value: SendView[]): Promise<void> {
|
||||
await this.activeUserDecryptedState.update(() => value);
|
||||
}
|
||||
}
|
|
@ -18,10 +18,6 @@ export abstract class SendService {
|
|||
password: string,
|
||||
key?: SymmetricCryptoKey,
|
||||
) => Promise<[Send, EncArrayBuffer]>;
|
||||
/**
|
||||
* @deprecated Do not call this, use the get$ method
|
||||
*/
|
||||
get: (id: string) => Send;
|
||||
/**
|
||||
* Provides a send for a determined id
|
||||
* updates after a change occurs to the send that matches the id
|
||||
|
@ -53,6 +49,5 @@ export abstract class SendService {
|
|||
export abstract class InternalSendService extends SendService {
|
||||
upsert: (send: SendData | SendData[]) => Promise<any>;
|
||||
replace: (sends: { [id: string]: SendData }) => Promise<void>;
|
||||
clear: (userId: string) => Promise<any>;
|
||||
delete: (id: string | string[]) => Promise<any>;
|
||||
}
|
||||
|
|
|
@ -1,14 +1,23 @@
|
|||
import { any, mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
FakeActiveUserState,
|
||||
FakeStateProvider,
|
||||
awaitAsync,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { CryptoService } from "../../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ContainerService } from "../../../platform/services/container.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendFileApi } from "../models/api/send-file.api";
|
||||
|
@ -16,10 +25,17 @@ import { SendTextApi } from "../models/api/send-text.api";
|
|||
import { SendFileData } from "../models/data/send-file.data";
|
||||
import { SendTextData } from "../models/data/send-text.data";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { Send } from "../models/domain/send";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
|
||||
import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions";
|
||||
import { SendStateProvider } from "./send-state.provider";
|
||||
import { SendService } from "./send.service";
|
||||
import {
|
||||
createSendData,
|
||||
testSend,
|
||||
testSendData,
|
||||
testSendViewData,
|
||||
} from "./test-data/send-tests.data";
|
||||
|
||||
describe("SendService", () => {
|
||||
const cryptoService = mock<CryptoService>();
|
||||
|
@ -27,56 +43,53 @@ describe("SendService", () => {
|
|||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
|
||||
let sendStateProvider: SendStateProvider;
|
||||
let sendService: SendService;
|
||||
|
||||
let stateService: MockProxy<StateService>;
|
||||
let activeAccount: BehaviorSubject<string>;
|
||||
let activeAccountUnlocked: BehaviorSubject<boolean>;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let encryptedState: FakeActiveUserState<Record<string, SendData>>;
|
||||
let decryptedState: FakeActiveUserState<SendView[]>;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
|
||||
beforeEach(() => {
|
||||
activeAccount = new BehaviorSubject("123");
|
||||
activeAccountUnlocked = new BehaviorSubject(true);
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
sendStateProvider = new SendStateProvider(stateProvider);
|
||||
|
||||
stateService = mock<StateService>();
|
||||
stateService.activeAccount$ = activeAccount;
|
||||
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
|
||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
|
||||
|
||||
stateService.getEncryptedSends.calledWith(any()).mockResolvedValue({
|
||||
"1": sendData("1", "Test Send"),
|
||||
accountService.activeAccountSubject.next({
|
||||
id: mockUserId,
|
||||
email: "email",
|
||||
name: "name",
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
|
||||
stateService.getDecryptedSends
|
||||
.calledWith(any())
|
||||
.mockResolvedValue([sendView("1", "Test Send")]);
|
||||
|
||||
sendService = new SendService(cryptoService, i18nService, keyGenerationService, stateService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
activeAccount.complete();
|
||||
activeAccountUnlocked.complete();
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("exists", async () => {
|
||||
const result = sendService.get("1");
|
||||
|
||||
expect(result).toEqual(send("1", "Test Send"));
|
||||
// Initial encrypted state
|
||||
encryptedState = stateProvider.activeUser.getFake(SEND_USER_ENCRYPTED);
|
||||
encryptedState.nextState({
|
||||
"1": testSendData("1", "Test Send"),
|
||||
});
|
||||
// Initial decrypted state
|
||||
decryptedState = stateProvider.activeUser.getFake(SEND_USER_DECRYPTED);
|
||||
decryptedState.nextState([testSendViewData("1", "Test Send")]);
|
||||
|
||||
it("does not exist", async () => {
|
||||
const result = sendService.get("2");
|
||||
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
sendService = new SendService(
|
||||
cryptoService,
|
||||
i18nService,
|
||||
keyGenerationService,
|
||||
sendStateProvider,
|
||||
encryptService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("get$", () => {
|
||||
it("exists", async () => {
|
||||
const result = await firstValueFrom(sendService.get$("1"));
|
||||
|
||||
expect(result).toEqual(send("1", "Test Send"));
|
||||
expect(result).toEqual(testSend("1", "Test Send"));
|
||||
});
|
||||
|
||||
it("does not exist", async () => {
|
||||
|
@ -88,14 +101,14 @@ describe("SendService", () => {
|
|||
it("updated observable", async () => {
|
||||
const singleSendObservable = sendService.get$("1");
|
||||
const result = await firstValueFrom(singleSendObservable);
|
||||
expect(result).toEqual(send("1", "Test Send"));
|
||||
expect(result).toEqual(testSend("1", "Test Send"));
|
||||
|
||||
await sendService.replace({
|
||||
"1": sendData("1", "Test Send Updated"),
|
||||
"1": testSendData("1", "Test Send Updated"),
|
||||
});
|
||||
|
||||
const result2 = await firstValueFrom(singleSendObservable);
|
||||
expect(result2).toEqual(send("1", "Test Send Updated"));
|
||||
expect(result2).toEqual(testSend("1", "Test Send Updated"));
|
||||
});
|
||||
|
||||
it("reports a change when name changes on a new send", async () => {
|
||||
|
@ -103,13 +116,13 @@ describe("SendService", () => {
|
|||
sendService.get$("1").subscribe(() => {
|
||||
changed = true;
|
||||
});
|
||||
const sendDataObject = sendData("1", "Test Send 2");
|
||||
const sendDataObject = testSendData("1", "Test Send 2");
|
||||
|
||||
//it is immediately called when subscribed, we need to reset the value
|
||||
changed = false;
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
|
@ -120,7 +133,7 @@ describe("SendService", () => {
|
|||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
@ -134,7 +147,7 @@ describe("SendService", () => {
|
|||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
|
@ -145,7 +158,7 @@ describe("SendService", () => {
|
|||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
@ -159,7 +172,7 @@ describe("SendService", () => {
|
|||
sendDataObject.text.text = "new text";
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
|
@ -170,7 +183,7 @@ describe("SendService", () => {
|
|||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
@ -184,7 +197,7 @@ describe("SendService", () => {
|
|||
sendDataObject.text = null;
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
|
@ -197,7 +210,7 @@ describe("SendService", () => {
|
|||
}) as SendData;
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
sendDataObject.file = new SendFileData(new SendFileApi({ FileName: "updated name of file" }));
|
||||
|
@ -211,7 +224,7 @@ describe("SendService", () => {
|
|||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(false);
|
||||
|
@ -222,7 +235,7 @@ describe("SendService", () => {
|
|||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
@ -236,7 +249,7 @@ describe("SendService", () => {
|
|||
sendDataObject.key = "newKey";
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
|
@ -247,7 +260,7 @@ describe("SendService", () => {
|
|||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
@ -261,7 +274,7 @@ describe("SendService", () => {
|
|||
sendDataObject.revisionDate = "2025-04-05";
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
|
@ -272,7 +285,7 @@ describe("SendService", () => {
|
|||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
@ -286,7 +299,7 @@ describe("SendService", () => {
|
|||
sendDataObject.name = null;
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
|
@ -299,7 +312,7 @@ describe("SendService", () => {
|
|||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
@ -312,7 +325,7 @@ describe("SendService", () => {
|
|||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(false);
|
||||
|
@ -320,7 +333,7 @@ describe("SendService", () => {
|
|||
sendDataObject.text.text = "Asdf";
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
|
@ -332,14 +345,14 @@ describe("SendService", () => {
|
|||
changed = true;
|
||||
});
|
||||
|
||||
const sendDataObject = sendData("1", "Test Send");
|
||||
const sendDataObject = testSendData("1", "Test Send");
|
||||
|
||||
//it is immediately called when subscribed, we need to reset the value
|
||||
changed = false;
|
||||
|
||||
await sendService.replace({
|
||||
"1": sendDataObject,
|
||||
"2": sendData("3", "Test Send 3"),
|
||||
"2": testSendData("3", "Test Send 3"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(false);
|
||||
|
@ -354,7 +367,7 @@ describe("SendService", () => {
|
|||
changed = false;
|
||||
|
||||
await sendService.replace({
|
||||
"2": sendData("2", "Test Send 2"),
|
||||
"2": testSendData("2", "Test Send 2"),
|
||||
});
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
|
@ -366,14 +379,14 @@ describe("SendService", () => {
|
|||
const send1 = sends[0];
|
||||
|
||||
expect(sends).toHaveLength(1);
|
||||
expect(send1).toEqual(send("1", "Test Send"));
|
||||
expect(send1).toEqual(testSend("1", "Test Send"));
|
||||
});
|
||||
|
||||
describe("getFromState", () => {
|
||||
it("exists", async () => {
|
||||
const result = await sendService.getFromState("1");
|
||||
|
||||
expect(result).toEqual(send("1", "Test Send"));
|
||||
expect(result).toEqual(testSend("1", "Test Send"));
|
||||
});
|
||||
it("does not exist", async () => {
|
||||
const result = await sendService.getFromState("2");
|
||||
|
@ -383,17 +396,17 @@ describe("SendService", () => {
|
|||
});
|
||||
|
||||
it("getAllDecryptedFromState", async () => {
|
||||
await sendService.getAllDecryptedFromState();
|
||||
const sends = await sendService.getAllDecryptedFromState();
|
||||
|
||||
expect(stateService.getDecryptedSends).toHaveBeenCalledTimes(1);
|
||||
expect(sends[0]).toMatchObject(testSendViewData("1", "Test Send"));
|
||||
});
|
||||
|
||||
describe("getRotatedKeys", () => {
|
||||
let encryptedKey: EncString;
|
||||
beforeEach(() => {
|
||||
cryptoService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||
encryptedKey = new EncString("Re-encrypted Send Key");
|
||||
cryptoService.encrypt.mockResolvedValue(encryptedKey);
|
||||
encryptService.encrypt.mockResolvedValue(encryptedKey);
|
||||
});
|
||||
|
||||
it("returns re-encrypted user sends", async () => {
|
||||
|
@ -408,6 +421,8 @@ describe("SendService", () => {
|
|||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
sendService.replace(null);
|
||||
|
||||
await awaitAsync();
|
||||
|
||||
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const result = await sendService.getRotatedKeys(newUserKey);
|
||||
|
||||
|
@ -424,114 +439,51 @@ describe("SendService", () => {
|
|||
// InternalSendService
|
||||
|
||||
it("upsert", async () => {
|
||||
await sendService.upsert(sendData("2", "Test 2"));
|
||||
await sendService.upsert(testSendData("2", "Test 2"));
|
||||
|
||||
expect(await firstValueFrom(sendService.sends$)).toEqual([
|
||||
send("1", "Test Send"),
|
||||
send("2", "Test 2"),
|
||||
testSend("1", "Test Send"),
|
||||
testSend("2", "Test 2"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("replace", async () => {
|
||||
await sendService.replace({ "2": sendData("2", "test 2") });
|
||||
await sendService.replace({ "2": testSendData("2", "test 2") });
|
||||
|
||||
expect(await firstValueFrom(sendService.sends$)).toEqual([send("2", "test 2")]);
|
||||
expect(await firstValueFrom(sendService.sends$)).toEqual([testSend("2", "test 2")]);
|
||||
});
|
||||
|
||||
it("clear", async () => {
|
||||
await sendService.clear();
|
||||
|
||||
await awaitAsync();
|
||||
expect(await firstValueFrom(sendService.sends$)).toEqual([]);
|
||||
});
|
||||
describe("Delete", () => {
|
||||
it("Sends count should decrease after delete", async () => {
|
||||
const sendsBeforeDelete = await firstValueFrom(sendService.sends$);
|
||||
await sendService.delete(sendsBeforeDelete[0].id);
|
||||
|
||||
describe("delete", () => {
|
||||
it("exists", async () => {
|
||||
await sendService.delete("1");
|
||||
|
||||
expect(stateService.getEncryptedSends).toHaveBeenCalledTimes(2);
|
||||
expect(stateService.setEncryptedSends).toHaveBeenCalledTimes(1);
|
||||
const sendsAfterDelete = await firstValueFrom(sendService.sends$);
|
||||
expect(sendsAfterDelete.length).toBeLessThan(sendsBeforeDelete.length);
|
||||
});
|
||||
|
||||
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
|
||||
sendService.delete("1");
|
||||
it("Intended send should be delete", async () => {
|
||||
const sendsBeforeDelete = await firstValueFrom(sendService.sends$);
|
||||
await sendService.delete(sendsBeforeDelete[0].id);
|
||||
const sendsAfterDelete = await firstValueFrom(sendService.sends$);
|
||||
expect(sendsAfterDelete[0]).not.toBe(sendsBeforeDelete[0]);
|
||||
});
|
||||
|
||||
expect(stateService.getEncryptedSends).toHaveBeenCalledTimes(2);
|
||||
it("Deleting on an empty sends array should not throw", async () => {
|
||||
sendStateProvider.getEncryptedSends = jest.fn().mockResolvedValue(null);
|
||||
await expect(sendService.delete("2")).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("Delete multiple sends", async () => {
|
||||
await sendService.upsert(testSendData("2", "send data 2"));
|
||||
await sendService.delete(["1", "2"]);
|
||||
const sendsAfterDelete = await firstValueFrom(sendService.sends$);
|
||||
expect(sendsAfterDelete.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Send object helper functions
|
||||
|
||||
function sendData(id: string, name: string) {
|
||||
const data = new SendData({} as any);
|
||||
data.id = id;
|
||||
data.name = name;
|
||||
data.disabled = false;
|
||||
data.accessCount = 2;
|
||||
data.accessId = "1";
|
||||
data.revisionDate = null;
|
||||
data.expirationDate = null;
|
||||
data.deletionDate = null;
|
||||
data.notes = "Notes!!";
|
||||
data.key = null;
|
||||
return data;
|
||||
}
|
||||
|
||||
const defaultSendData: Partial<SendData> = {
|
||||
id: "1",
|
||||
name: "Test Send",
|
||||
accessId: "123",
|
||||
type: SendType.Text,
|
||||
notes: "notes!",
|
||||
file: null,
|
||||
text: new SendTextData(new SendTextApi({ Text: "send text" })),
|
||||
key: "key",
|
||||
maxAccessCount: 12,
|
||||
accessCount: 2,
|
||||
revisionDate: "2024-09-04",
|
||||
expirationDate: "2024-09-04",
|
||||
deletionDate: "2024-09-04",
|
||||
password: "password",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
};
|
||||
|
||||
function createSendData(value: Partial<SendData> = {}) {
|
||||
const testSend: any = {};
|
||||
for (const prop in defaultSendData) {
|
||||
testSend[prop] = value[prop as keyof SendData] ?? defaultSendData[prop as keyof SendData];
|
||||
}
|
||||
return testSend;
|
||||
}
|
||||
|
||||
function sendView(id: string, name: string) {
|
||||
const data = new SendView({} as any);
|
||||
data.id = id;
|
||||
data.name = name;
|
||||
data.disabled = false;
|
||||
data.accessCount = 2;
|
||||
data.accessId = "1";
|
||||
data.revisionDate = null;
|
||||
data.expirationDate = null;
|
||||
data.deletionDate = null;
|
||||
data.notes = "Notes!!";
|
||||
data.key = null;
|
||||
return data;
|
||||
}
|
||||
|
||||
function send(id: string, name: string) {
|
||||
const data = new Send({} as any);
|
||||
data.id = id;
|
||||
data.name = new EncString(name);
|
||||
data.disabled = false;
|
||||
data.accessCount = 2;
|
||||
data.accessId = "1";
|
||||
data.revisionDate = null;
|
||||
data.expirationDate = null;
|
||||
data.deletionDate = null;
|
||||
data.notes = new EncString("Notes!!");
|
||||
data.key = null;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { BehaviorSubject, Observable, concatMap, distinctUntilChanged, map } from "rxjs";
|
||||
import { Observable, concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { CryptoService } from "../../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { KdfType } from "../../../platform/enums";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
|
@ -19,48 +19,29 @@ import { SendWithIdRequest } from "../models/request/send-with-id.request";
|
|||
import { SendView } from "../models/view/send.view";
|
||||
import { SEND_KDF_ITERATIONS } from "../send-kdf";
|
||||
|
||||
import { SendStateProvider } from "./send-state.provider.abstraction";
|
||||
import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction";
|
||||
|
||||
export class SendService implements InternalSendServiceAbstraction {
|
||||
readonly sendKeySalt = "bitwarden-send";
|
||||
readonly sendKeyPurpose = "send";
|
||||
|
||||
protected _sends: BehaviorSubject<Send[]> = new BehaviorSubject([]);
|
||||
protected _sendViews: BehaviorSubject<SendView[]> = new BehaviorSubject([]);
|
||||
|
||||
sends$ = this._sends.asObservable();
|
||||
sendViews$ = this._sendViews.asObservable();
|
||||
sends$ = this.stateProvider.encryptedState$.pipe(
|
||||
map((record) => Object.values(record || {}).map((data) => new Send(data))),
|
||||
);
|
||||
sendViews$ = this.stateProvider.encryptedState$.pipe(
|
||||
concatMap((record) =>
|
||||
this.decryptSends(Object.values(record || {}).map((data) => new Send(data))),
|
||||
),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private cryptoService: CryptoService,
|
||||
private i18nService: I18nService,
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private stateService: StateService,
|
||||
) {
|
||||
this.stateService.activeAccountUnlocked$
|
||||
.pipe(
|
||||
concatMap(async (unlocked) => {
|
||||
if (Utils.global.bitwardenContainerService == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unlocked) {
|
||||
this._sends.next([]);
|
||||
this._sendViews.next([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await this.stateService.getEncryptedSends();
|
||||
|
||||
await this.updateObservables(data);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async clearCache(): Promise<void> {
|
||||
await this._sendViews.next([]);
|
||||
}
|
||||
private stateProvider: SendStateProvider,
|
||||
private encryptService: EncryptService,
|
||||
) {}
|
||||
|
||||
async encrypt(
|
||||
model: SendView,
|
||||
|
@ -93,12 +74,15 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||
);
|
||||
send.password = passwordKey.keyB64;
|
||||
}
|
||||
send.key = await this.cryptoService.encrypt(model.key, key);
|
||||
send.name = await this.cryptoService.encrypt(model.name, model.cryptoKey);
|
||||
send.notes = await this.cryptoService.encrypt(model.notes, model.cryptoKey);
|
||||
if (key == null) {
|
||||
key = await this.cryptoService.getUserKey();
|
||||
}
|
||||
send.key = await this.encryptService.encrypt(model.key, key);
|
||||
send.name = await this.encryptService.encrypt(model.name, model.cryptoKey);
|
||||
send.notes = await this.encryptService.encrypt(model.notes, model.cryptoKey);
|
||||
if (send.type === SendType.Text) {
|
||||
send.text = new SendText();
|
||||
send.text.text = await this.cryptoService.encrypt(model.text.text, model.cryptoKey);
|
||||
send.text.text = await this.encryptService.encrypt(model.text.text, model.cryptoKey);
|
||||
send.text.hidden = model.text.hidden;
|
||||
} else if (send.type === SendType.File) {
|
||||
send.file = new SendFile();
|
||||
|
@ -120,11 +104,6 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||
return [send, fileData];
|
||||
}
|
||||
|
||||
get(id: string): Send {
|
||||
const sends = this._sends.getValue();
|
||||
return sends.find((send) => send.id === id);
|
||||
}
|
||||
|
||||
get$(id: string): Observable<Send | undefined> {
|
||||
return this.sends$.pipe(
|
||||
distinctUntilChanged((oldSends, newSends) => {
|
||||
|
@ -188,7 +167,7 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||
}
|
||||
|
||||
async getFromState(id: string): Promise<Send> {
|
||||
const sends = await this.stateService.getEncryptedSends();
|
||||
const sends = await this.stateProvider.getEncryptedSends();
|
||||
// eslint-disable-next-line
|
||||
if (sends == null || !sends.hasOwnProperty(id)) {
|
||||
return null;
|
||||
|
@ -198,7 +177,7 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||
}
|
||||
|
||||
async getAll(): Promise<Send[]> {
|
||||
const sends = await this.stateService.getEncryptedSends();
|
||||
const sends = await this.stateProvider.getEncryptedSends();
|
||||
const response: Send[] = [];
|
||||
for (const id in sends) {
|
||||
// eslint-disable-next-line
|
||||
|
@ -210,7 +189,7 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||
}
|
||||
|
||||
async getAllDecryptedFromState(): Promise<SendView[]> {
|
||||
let decSends = await this.stateService.getDecryptedSends();
|
||||
let decSends = await this.stateProvider.getDecryptedSends();
|
||||
if (decSends != null) {
|
||||
return decSends;
|
||||
}
|
||||
|
@ -230,12 +209,12 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||
await Promise.all(promises);
|
||||
decSends.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
|
||||
await this.stateService.setDecryptedSends(decSends);
|
||||
await this.stateProvider.setDecryptedSends(decSends);
|
||||
return decSends;
|
||||
}
|
||||
|
||||
async upsert(send: SendData | SendData[]): Promise<any> {
|
||||
let sends = await this.stateService.getEncryptedSends();
|
||||
let sends = await this.stateProvider.getEncryptedSends();
|
||||
if (sends == null) {
|
||||
sends = {};
|
||||
}
|
||||
|
@ -252,16 +231,12 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||
}
|
||||
|
||||
async clear(userId?: string): Promise<any> {
|
||||
if (userId == null || userId == (await this.stateService.getUserId())) {
|
||||
this._sends.next([]);
|
||||
this._sendViews.next([]);
|
||||
}
|
||||
await this.stateService.setDecryptedSends(null, { userId: userId });
|
||||
await this.stateService.setEncryptedSends(null, { userId: userId });
|
||||
await this.stateProvider.setDecryptedSends(null);
|
||||
await this.stateProvider.setEncryptedSends(null);
|
||||
}
|
||||
|
||||
async delete(id: string | string[]): Promise<any> {
|
||||
const sends = await this.stateService.getEncryptedSends();
|
||||
const sends = await this.stateProvider.getEncryptedSends();
|
||||
if (sends == null) {
|
||||
return;
|
||||
}
|
||||
|
@ -281,8 +256,7 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||
}
|
||||
|
||||
async replace(sends: { [id: string]: SendData }): Promise<any> {
|
||||
await this.updateObservables(sends);
|
||||
await this.stateService.setEncryptedSends(sends);
|
||||
await this.stateProvider.setEncryptedSends(sends);
|
||||
}
|
||||
|
||||
async getRotatedKeys(newUserKey: UserKey): Promise<SendWithIdRequest[]> {
|
||||
|
@ -290,14 +264,21 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||
throw new Error("New user key is required for rotation.");
|
||||
}
|
||||
|
||||
const req = await firstValueFrom(
|
||||
this.sends$.pipe(concatMap(async (sends) => this.toRotatedKeyRequestMap(sends, newUserKey))),
|
||||
);
|
||||
// separate return for easier debugging
|
||||
return req;
|
||||
}
|
||||
|
||||
private async toRotatedKeyRequestMap(sends: Send[], newUserKey: UserKey) {
|
||||
const requests = await Promise.all(
|
||||
this._sends.value.map(async (send) => {
|
||||
const sendKey = await this.cryptoService.decryptToBytes(send.key);
|
||||
send.key = await this.cryptoService.encrypt(sendKey, newUserKey);
|
||||
sends.map(async (send) => {
|
||||
const sendKey = await this.encryptService.decryptToBytes(send.key, newUserKey);
|
||||
send.key = await this.encryptService.encrypt(sendKey, newUserKey);
|
||||
return new SendWithIdRequest(send);
|
||||
}),
|
||||
);
|
||||
// separate return for easier debugging
|
||||
return requests;
|
||||
}
|
||||
|
||||
|
@ -329,18 +310,12 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||
data: ArrayBuffer,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<[EncString, EncArrayBuffer]> {
|
||||
const encFileName = await this.cryptoService.encrypt(fileName, key);
|
||||
const encFileData = await this.cryptoService.encryptToBytes(new Uint8Array(data), key);
|
||||
return [encFileName, encFileData];
|
||||
}
|
||||
|
||||
private async updateObservables(sendsMap: { [id: string]: SendData }) {
|
||||
const sends = Object.values(sendsMap || {}).map((f) => new Send(f));
|
||||
this._sends.next(sends);
|
||||
|
||||
if (await this.cryptoService.hasUserKey()) {
|
||||
this._sendViews.next(await this.decryptSends(sends));
|
||||
if (key == null) {
|
||||
key = await this.cryptoService.getUserKey();
|
||||
}
|
||||
const encFileName = await this.encryptService.encrypt(fileName, key);
|
||||
const encFileData = await this.encryptService.encryptToBytes(new Uint8Array(data), key);
|
||||
return [encFileName, encFileData];
|
||||
}
|
||||
|
||||
private async decryptSends(sends: Send[]) {
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import { EncString } from "../../../../platform/models/domain/enc-string";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendTextApi } from "../../models/api/send-text.api";
|
||||
import { SendTextData } from "../../models/data/send-text.data";
|
||||
import { SendData } from "../../models/data/send.data";
|
||||
import { Send } from "../../models/domain/send";
|
||||
import { SendView } from "../../models/view/send.view";
|
||||
|
||||
export function testSendViewData(id: string, name: string) {
|
||||
const data = new SendView({} as any);
|
||||
data.id = id;
|
||||
data.name = name;
|
||||
data.disabled = false;
|
||||
data.accessCount = 2;
|
||||
data.accessId = "1";
|
||||
data.revisionDate = null;
|
||||
data.expirationDate = null;
|
||||
data.deletionDate = null;
|
||||
data.notes = "Notes!!";
|
||||
data.key = null;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function createSendData(value: Partial<SendData> = {}) {
|
||||
const defaultSendData: Partial<SendData> = {
|
||||
id: "1",
|
||||
name: "Test Send",
|
||||
accessId: "123",
|
||||
type: SendType.Text,
|
||||
notes: "notes!",
|
||||
file: null,
|
||||
text: new SendTextData(new SendTextApi({ Text: "send text" })),
|
||||
key: "key",
|
||||
maxAccessCount: 12,
|
||||
accessCount: 2,
|
||||
revisionDate: "2024-09-04",
|
||||
expirationDate: "2024-09-04",
|
||||
deletionDate: "2024-09-04",
|
||||
password: "password",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
};
|
||||
|
||||
const testSend: any = {};
|
||||
for (const prop in defaultSendData) {
|
||||
testSend[prop] = value[prop as keyof SendData] ?? defaultSendData[prop as keyof SendData];
|
||||
}
|
||||
return testSend;
|
||||
}
|
||||
|
||||
export function testSendData(id: string, name: string) {
|
||||
const data = new SendData({} as any);
|
||||
data.id = id;
|
||||
data.name = name;
|
||||
data.disabled = false;
|
||||
data.accessCount = 2;
|
||||
data.accessId = "1";
|
||||
data.revisionDate = null;
|
||||
data.expirationDate = null;
|
||||
data.deletionDate = null;
|
||||
data.notes = "Notes!!";
|
||||
data.key = null;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function testSend(id: string, name: string) {
|
||||
const data = new Send({} as any);
|
||||
data.id = id;
|
||||
data.name = new EncString(name);
|
||||
data.disabled = false;
|
||||
data.accessCount = 2;
|
||||
data.accessId = "1";
|
||||
data.revisionDate = null;
|
||||
data.expirationDate = null;
|
||||
data.deletionDate = null;
|
||||
data.notes = new EncString("Notes!!");
|
||||
data.key = null;
|
||||
return data;
|
||||
}
|
|
@ -244,7 +244,7 @@ export class SyncService implements SyncServiceAbstraction {
|
|||
this.syncStarted();
|
||||
if (await this.stateService.getIsAuthenticated()) {
|
||||
try {
|
||||
const localSend = this.sendService.get(notification.id);
|
||||
const localSend = await firstValueFrom(this.sendService.get$(notification.id));
|
||||
if (
|
||||
(!isEdit && localSend == null) ||
|
||||
(isEdit && localSend != null && localSend.revisionDate < notification.revisionDate)
|
||||
|
|
Loading…
Reference in New Issue
Block a user