diff --git a/apps/browser/config/base.json b/apps/browser/config/base.json index 5113cd7d1bf..91d48309240 100644 --- a/apps/browser/config/base.json +++ b/apps/browser/config/base.json @@ -2,6 +2,7 @@ "devFlags": {}, "flags": { "showPasswordless": true, - "accountSwitching": false + "accountSwitching": false, + "sdk": true } } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index fd513f8792d..ec1842e1053 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -80,6 +80,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; import { Fido2ActiveRequestManager as Fido2ActiveRequestManagerAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-active-request-manager.abstraction"; import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction"; import { Fido2ClientService as Fido2ClientServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction"; @@ -89,6 +90,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService, @@ -121,6 +123,8 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; +import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; +import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; @@ -227,6 +231,7 @@ import AutofillService from "../autofill/services/autofill.service"; import { SafariApp } from "../browser/safariApp"; import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service"; import { BrowserApi } from "../platform/browser/browser-api"; +import { flagEnabled } from "../platform/flags"; import { UpdateBadge } from "../platform/listeners/update-badge"; /* eslint-disable no-restricted-imports */ import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender"; @@ -244,6 +249,7 @@ import { LocalBackedSessionStorageService } from "../platform/services/local-bac import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service"; import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; import { PopupViewCacheBackgroundService } from "../platform/services/popup-view-cache-background.service"; +import { BrowserSdkClientFactory } from "../platform/services/sdk/browser-sdk-client-factory"; import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service"; import { ForegroundTaskSchedulerService } from "../platform/services/task-scheduler/foreground-task-scheduler.service"; import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service"; @@ -363,6 +369,7 @@ export default class MainBackground { syncServiceListener: SyncServiceListener; themeStateService: DefaultThemeStateService; autoSubmitLoginBackground: AutoSubmitLoginBackground; + sdkService: SdkService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -570,6 +577,7 @@ export default class MainBackground { this.logService, this.stateProvider, this.accountService, + process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[], ); this.biometricStateService = new DefaultBiometricStateService(this.stateProvider); @@ -717,6 +725,16 @@ export default class MainBackground { this.stateProvider, ); + const sdkClientFactory = flagEnabled("sdk") + ? new BrowserSdkClientFactory() + : new NoopSdkClientFactory(); + this.sdkService = new DefaultSdkService( + sdkClientFactory, + this.environmentService, + this.platformUtilsService, + this.apiService, + ); + this.passwordStrengthService = new PasswordStrengthService(); this.passwordGenerationService = legacyPasswordGenerationServiceFactory( @@ -1313,6 +1331,20 @@ export default class MainBackground { await this.initOverlayAndTabsBackground(); + if (flagEnabled("sdk")) { + // Warn if the SDK for some reason can't be initialized + let supported = false; + try { + supported = await firstValueFrom(this.sdkService.supported$); + } catch (e) { + // Do nothing. + } + + if (!supported) { + this.sdkService.failedToInitialize().catch(this.logService.error); + } + } + return new Promise((resolve) => { setTimeout(async () => { await this.refreshBadge(); diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 5413ee5b63f..762e5e01f83 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -39,8 +39,7 @@ } ], "background": { - "service_worker": "background.js", - "type": "module" + "service_worker": "background.js" }, "action": { "default_icon": { diff --git a/apps/browser/src/platform/services/browser-environment.service.ts b/apps/browser/src/platform/services/browser-environment.service.ts index d7e22cf747e..89f05579c88 100644 --- a/apps/browser/src/platform/services/browser-environment.service.ts +++ b/apps/browser/src/platform/services/browser-environment.service.ts @@ -1,7 +1,7 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { Region } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Region, RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -14,8 +14,9 @@ export class BrowserEnvironmentService extends DefaultEnvironmentService { private logService: LogService, stateProvider: StateProvider, accountService: AccountService, + additionalRegionConfigs: RegionConfig[] = [], ) { - super(stateProvider, accountService); + super(stateProvider, accountService, additionalRegionConfigs); } async hasManagedEnvironment(): Promise { diff --git a/apps/browser/src/platform/services/sdk/browser-sdk-client-factory.ts b/apps/browser/src/platform/services/sdk/browser-sdk-client-factory.ts new file mode 100644 index 00000000000..aa8bfe61c27 --- /dev/null +++ b/apps/browser/src/platform/services/sdk/browser-sdk-client-factory.ts @@ -0,0 +1,42 @@ +import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; +import type { BitwardenClient } from "@bitwarden/sdk-internal"; + +// https://stackoverflow.com/a/47880734 +const supported = (() => { + try { + if (typeof WebAssembly === "object" && typeof WebAssembly.instantiate === "function") { + const module = new WebAssembly.Module( + Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00), + ); + if (module instanceof WebAssembly.Module) { + return new WebAssembly.Instance(module) instanceof WebAssembly.Instance; + } + } + } catch (e) { + // ignore + } + return false; +})(); + +if (supported) { + // eslint-disable-next-line no-console + console.debug("WebAssembly is supported in this environment"); + import("./wasm"); +} else { + // eslint-disable-next-line no-console + console.debug("WebAssembly is not supported in this environment"); + import("./fallback"); +} + +/** + * SDK client factory with a js fallback for when WASM is not supported. + * + * Works both in popup and service worker. + */ +export class BrowserSdkClientFactory implements SdkClientFactory { + async createSdkClient( + ...args: ConstructorParameters + ): Promise { + return Promise.resolve((globalThis as any).init_sdk(...args)); + } +} diff --git a/apps/browser/src/platform/services/sdk/fallback.ts b/apps/browser/src/platform/services/sdk/fallback.ts new file mode 100644 index 00000000000..82d292fc9ee --- /dev/null +++ b/apps/browser/src/platform/services/sdk/fallback.ts @@ -0,0 +1,8 @@ +import * as sdk from "@bitwarden/sdk-internal"; +import * as wasm from "@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm.js"; + +(globalThis as any).init_sdk = (...args: ConstructorParameters) => { + (sdk as any).init(wasm); + + return new sdk.BitwardenClient(...args); +}; diff --git a/apps/browser/src/platform/services/sdk/wasm.ts b/apps/browser/src/platform/services/sdk/wasm.ts new file mode 100644 index 00000000000..1977a171e23 --- /dev/null +++ b/apps/browser/src/platform/services/sdk/wasm.ts @@ -0,0 +1,8 @@ +import * as sdk from "@bitwarden/sdk-internal"; +import * as wasm from "@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm"; + +(globalThis as any).init_sdk = (...args: ConstructorParameters) => { + (sdk as any).init(wasm); + + return new sdk.BitwardenClient(...args); +}; diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 12d5b109c20..113cd736c6a 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs"; +import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap, catchError, of } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -8,7 +9,9 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; @@ -20,6 +23,7 @@ import { ToastService, } from "@bitwarden/components"; +import { flagEnabled } from "../platform/flags"; import { PopupViewCacheService } from "../platform/popup/view-cache/popup-view-cache.service"; import { initPopupClosedListener } from "../platform/services/popup-view-cache-background.service"; import { BrowserSendStateService } from "../tools/popup/services/browser-send-state.service"; @@ -62,7 +66,28 @@ export class AppComponent implements OnInit, OnDestroy { private toastService: ToastService, private accountService: AccountService, private animationControlService: AnimationControlService, - ) {} + private logService: LogService, + private sdkService: SdkService, + ) { + if (flagEnabled("sdk")) { + // Warn if the SDK for some reason can't be initialized + this.sdkService.supported$ + .pipe( + takeUntilDestroyed(), + catchError(() => { + return of(false); + }), + ) + .subscribe((supported) => { + if (!supported) { + this.logService.debug("SDK is not supported"); + this.sdkService.failedToInitialize().catch(this.logService.error); + } else { + this.logService.debug("SDK is supported"); + } + }); + } + } async ngOnInit() { initPopupClosedListener(); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 024b4f46315..65bcd81072e 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -14,6 +14,7 @@ import { DEFAULT_VAULT_TIMEOUT, INTRAPROCESS_MESSAGING_SUBJECT, CLIENT_TYPE, + ENV_ADDITIONAL_REGIONS, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { AnonLayoutWrapperDataService, LockComponentService } from "@bitwarden/auth/angular"; @@ -57,6 +58,7 @@ import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/ke import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService, @@ -65,9 +67,11 @@ import { import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; +import { flagEnabled } from "@bitwarden/common/platform/misc/flags"; import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; +import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { @@ -113,6 +117,7 @@ import BrowserLocalStorageService from "../../platform/services/browser-local-st import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; import I18nService from "../../platform/services/i18n.service"; import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service"; +import { BrowserSdkClientFactory } from "../../platform/services/sdk/browser-sdk-client-factory"; import { ForegroundTaskSchedulerService } from "../../platform/services/task-scheduler/foreground-task-scheduler.service"; import { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; @@ -197,7 +202,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: BrowserEnvironmentService, useClass: BrowserEnvironmentService, - deps: [LogService, StateProvider, AccountServiceAbstraction], + deps: [LogService, StateProvider, AccountServiceAbstraction, ENV_ADDITIONAL_REGIONS], }), safeProvider({ provide: I18nServiceAbstraction, @@ -571,6 +576,11 @@ const safeProviders: SafeProvider[] = [ useClass: ForegroundLockService, deps: [MessageSender, MessageListener], }), + safeProvider({ + provide: SdkClientFactory, + useClass: flagEnabled("sdk") ? BrowserSdkClientFactory : NoopSdkClientFactory, + deps: [], + }), ]; @NgModule({ diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 7ac5a635b18..4309defd3a8 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -122,7 +122,7 @@ const moduleRules = [ loader: "@ngtools/webpack", }, { - test: /\.wasm$/, + test: /argon2(-simd)?\.wasm$/, loader: "base64-loader", type: "javascript/auto", }, @@ -320,9 +320,12 @@ const mainConfig = { clean: true, }, module: { - noParse: /\.wasm$/, + noParse: /argon2(-simd)?\.wasm$/, rules: moduleRules, }, + experiments: { + asyncWebAssembly: true, + }, plugins: plugins, }; @@ -395,12 +398,15 @@ if (manifestVersion == 2) { loader: "ts-loader", }, { - test: /\.wasm$/, + test: /argon2(-simd)?\.wasm$/, loader: "base64-loader", type: "javascript/auto", }, ], - noParse: /\.wasm$/, + noParse: /argon2(-simd)?\.wasm$/, + }, + experiments: { + asyncWebAssembly: true, }, resolve: { extensions: [".ts", ".js"], diff --git a/apps/cli/config/base.json b/apps/cli/config/base.json new file mode 100644 index 00000000000..ccf867c0dcc --- /dev/null +++ b/apps/cli/config/base.json @@ -0,0 +1,5 @@ +{ + "flags": { + "sdk": true + } +} diff --git a/apps/cli/config/config.js b/apps/cli/config/config.js index 81e2d619fee..cff42ecf62e 100644 --- a/apps/cli/config/config.js +++ b/apps/cli/config/config.js @@ -1,7 +1,27 @@ function load(envName) { + const base = require("./base.json"); + const env = loadConfig(envName); + const local = loadConfig("local"); + return { - ...loadConfig(envName), - ...loadConfig("local"), + ...base, + ...env, + ...local, + dev: { + ...base.dev, + ...env.dev, + ...local.dev, + }, + flags: { + ...base.flags, + ...env.flags, + ...local.flags, + }, + devFlags: { + ...base.devFlags, + ...env.devFlags, + ...local.devFlags, + }, }; } diff --git a/apps/cli/jest.config.js b/apps/cli/jest.config.js index 8765ccc8e4e..e0a5b9ec9cc 100644 --- a/apps/cli/jest.config.js +++ b/apps/cli/jest.config.js @@ -10,7 +10,11 @@ module.exports = { preset: "ts-jest", testEnvironment: "node", setupFilesAfterEnv: ["/test.setup.ts"], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { - prefix: "/", - }), + moduleNameMapper: { + "@bitwarden/common/platform/services/sdk/default-sdk-client-factory": + "/../../libs/common/spec/jest-sdk-client-factory", + ...pathsToModuleNameMapper(compilerOptions?.paths || {}, { + prefix: "/", + }), + }, }; diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index c5867281972..a249a4d3f32 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -59,8 +59,12 @@ import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/bill import { ClientType } from "@bitwarden/common/enums"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { + EnvironmentService, + RegionConfig, +} from "@bitwarden/common/platform/abstractions/environment.service"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { KeySuffixOptions, LogLevelType } from "@bitwarden/common/platform/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { MessageSender } from "@bitwarden/common/platform/messaging"; @@ -83,6 +87,9 @@ import { KeyGenerationService } from "@bitwarden/common/platform/services/key-ge import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; +import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory"; +import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; +import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; @@ -148,6 +155,7 @@ import { VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; +import { flagEnabled } from "../platform/flags"; import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service"; import { ConsoleLogService } from "../platform/services/console-log.service"; import { I18nService } from "../platform/services/i18n.service"; @@ -246,6 +254,7 @@ export class ServiceContainer { userAutoUnlockKeyService: UserAutoUnlockKeyService; kdfConfigService: KdfConfigServiceAbstraction; taskSchedulerService: TaskSchedulerService; + sdkService: SdkService; constructor() { let p = null; @@ -346,6 +355,7 @@ export class ServiceContainer { this.environmentService = new DefaultEnvironmentService( this.stateProvider, this.accountService, + process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[], ); this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); @@ -518,6 +528,17 @@ export class ServiceContainer { this.globalStateProvider, ); + const sdkClientFactory = flagEnabled("sdk") + ? new DefaultSdkClientFactory() + : new NoopSdkClientFactory(); + this.sdkService = new DefaultSdkService( + sdkClientFactory, + this.environmentService, + this.platformUtilsService, + this.apiService, + customUserAgent, + ); + this.passwordStrengthService = new PasswordStrengthService(); this.passwordGenerationService = legacyPasswordGenerationServiceFactory( @@ -826,5 +847,19 @@ export class ServiceContainer { } this.inited = true; + + if (flagEnabled("sdk")) { + // Warn if the SDK for some reason can't be initialized + let supported = false; + try { + supported = await firstValueFrom(this.sdkService.supported$); + } catch (e) { + // Do nothing. + } + + if (!supported) { + this.sdkService.failedToInitialize().catch(this.logService.error); + } + } } } diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index eedf24179d0..4cb450f9c69 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -3,7 +3,7 @@ "pretty": true, "moduleResolution": "node", "target": "ES2016", - "module": "es6", + "module": "ES2020", "noImplicitAny": true, "allowSyntheticDefaultImports": true, "emitDecoratorMetadata": true, diff --git a/apps/cli/webpack.config.js b/apps/cli/webpack.config.js index 2b9c53bac64..d5f66af73ec 100644 --- a/apps/cli/webpack.config.js +++ b/apps/cli/webpack.config.js @@ -37,8 +37,10 @@ const plugins = [ contextRegExp: /node-fetch/, }), new webpack.EnvironmentPlugin({ + ENV: ENV, BWCLI_ENV: ENV, FLAGS: envConfig.flags, + DEV_FLAGS: envConfig.devFlags, }), new webpack.IgnorePlugin({ resourceRegExp: /canvas/, @@ -79,6 +81,9 @@ const webpackConfig = { allowlist: [/@bitwarden/], }), ], + experiments: { + asyncWebAssembly: true, + }, }; module.exports = webpackConfig; diff --git a/apps/desktop/config/base.json b/apps/desktop/config/base.json index 3c93018e65f..5d045326d45 100644 --- a/apps/desktop/config/base.json +++ b/apps/desktop/config/base.json @@ -1,4 +1,6 @@ { - "devFlags": {}, - "flags": {} + "flags": { + "sdk": true + }, + "devFlags": {} } diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 9678f657234..61da12998d1 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -8,8 +8,9 @@ import { ViewChild, ViewContainerRef, } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { filter, firstValueFrom, map, Subject, takeUntil, timeout } from "rxjs"; +import { catchError, filter, firstValueFrom, map, of, Subject, takeUntil, timeout } from "rxjs"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -21,7 +22,6 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; @@ -38,6 +38,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize"; @@ -56,6 +57,7 @@ import { BiometricStateService } from "@bitwarden/key-management"; import { DeleteAccountComponent } from "../auth/delete-account.component"; import { LoginApprovalComponent } from "../auth/login/login-approval.component"; import { MenuAccount, MenuUpdateRequest } from "../main/menu/menu.updater"; +import { flagEnabled } from "../platform/flags"; import { PremiumComponent } from "../vault/app/accounts/premium.component"; import { FolderAddEditComponent } from "../vault/app/vault/folder-add-edit.component"; @@ -150,9 +152,28 @@ export class AppComponent implements OnInit, OnDestroy { private dialogService: DialogService, private biometricStateService: BiometricStateService, private stateEventRunnerService: StateEventRunnerService, - private providerService: ProviderService, private accountService: AccountService, - ) {} + private sdkService: SdkService, + ) { + if (flagEnabled("sdk")) { + // Warn if the SDK for some reason can't be initialized + this.sdkService.supported$ + .pipe( + takeUntilDestroyed(), + catchError(() => { + return of(false); + }), + ) + .subscribe((supported) => { + if (!supported) { + this.logService.debug("SDK is not supported"); + this.sdkService.failedToInitialize().catch(this.logService.error); + } else { + this.logService.debug("SDK is supported"); + } + }); + } + } ngOnInit() { this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => { diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index c6b73fbbbca..c9b434aa964 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -52,6 +52,7 @@ import { } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service"; @@ -60,6 +61,8 @@ import { Message, MessageListener, MessageSender } from "@bitwarden/common/platf import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; +import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory"; +import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; // eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage @@ -73,6 +76,7 @@ import { BiometricStateService, BiometricsService } from "@bitwarden/key-managem import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service"; +import { flagEnabled } from "../../platform/flags"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { ElectronCryptoService } from "../../platform/services/electron-crypto.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; @@ -302,6 +306,11 @@ const safeProviders: SafeProvider[] = [ InternalUserDecryptionOptionsServiceAbstraction, ], }), + safeProvider({ + provide: SdkClientFactory, + useClass: flagEnabled("sdk") ? DefaultSdkClientFactory : NoopSdkClientFactory, + deps: [], + }), ]; @NgModule({ diff --git a/apps/desktop/src/index.html b/apps/desktop/src/index.html index 6bac674bb5e..37eb64adf35 100644 --- a/apps/desktop/src/index.html +++ b/apps/desktop/src/index.html @@ -4,7 +4,7 @@ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 723b410f19b..a1b03509c70 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,6 +5,7 @@ import { Subject, firstValueFrom } from "rxjs"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { ClientType } from "@bitwarden/common/enums"; +import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; import { Message, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- For dependency creation import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; @@ -152,7 +153,11 @@ export class Main { new DefaultDerivedStateProvider(), ); - this.environmentService = new DefaultEnvironmentService(stateProvider, accountService); + this.environmentService = new DefaultEnvironmentService( + stateProvider, + accountService, + process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[], + ); this.migrationRunner = new MigrationRunner( this.storageService, diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts index ec57ecdf7bb..9c9f1ae6a9b 100644 --- a/apps/desktop/src/main/native-messaging.main.ts +++ b/apps/desktop/src/main/native-messaging.main.ts @@ -331,7 +331,7 @@ export class NativeMessagingMain { const ext = process.platform === "win32" ? ".exe" : ""; if (isDev()) { - return path.join( + const devPath = path.join( this.appPath, "..", "desktop_native", @@ -339,6 +339,12 @@ export class NativeMessagingMain { "debug", `desktop_proxy${ext}`, ); + + // isDev() returns true when using a production build with ELECTRON_IS_DEV=1, + // so we need to fall back to the prod binary if the dev binary doesn't exist. + if (existsSync(devPath)) { + return devPath; + } } return path.join(path.dirname(this.exePath), `desktop_proxy${ext}`); diff --git a/apps/desktop/webpack.renderer.js b/apps/desktop/webpack.renderer.js index dc3cdf1fef5..ac990689ae0 100644 --- a/apps/desktop/webpack.renderer.js +++ b/apps/desktop/webpack.renderer.js @@ -42,7 +42,7 @@ const common = { type: "asset/resource", }, { - test: /\.wasm$/, + test: /argon2(-simd)?\.wasm$/, loader: "base64-loader", type: "javascript/auto", }, @@ -143,11 +143,15 @@ const renderer = { parser: { system: true }, }, { - test: /\.wasm$/, + test: /argon2(-simd)?\.wasm$/, loader: "base64-loader", type: "javascript/auto", }, ], + noParse: /argon2(-simd)?\.wasm$/, + }, + experiments: { + asyncWebAssembly: true, }, plugins: [ new AngularWebpackPlugin({ diff --git a/apps/web/config/base.json b/apps/web/config/base.json index 8eb8a311335..cfaf604fb02 100644 --- a/apps/web/config/base.json +++ b/apps/web/config/base.json @@ -11,6 +11,8 @@ "allowedHosts": "auto" }, "flags": { - "showPasswordless": false - } + "showPasswordless": false, + "sdk": true + }, + "devFlags": {} } diff --git a/apps/web/jest.config.js b/apps/web/jest.config.js index f121823adee..9b5d6fdc766 100644 --- a/apps/web/jest.config.js +++ b/apps/web/jest.config.js @@ -9,11 +9,19 @@ module.exports = { ...sharedConfig, preset: "jest-preset-angular", setupFilesAfterEnv: ["/test.setup.ts"], - moduleNameMapper: pathsToModuleNameMapper( - // lets us use @bitwarden/common/spec in web tests - { "@bitwarden/common/spec": ["../../libs/common/spec"], ...(compilerOptions?.paths ?? {}) }, - { - prefix: "/", - }, - ), + moduleNameMapper: { + // Replace ESM SDK with Node compatible SDK + "@bitwarden/common/platform/services/sdk/default-sdk-client-factory": + "/../../libs/common/spec/jest-sdk-client-factory", + ...pathsToModuleNameMapper( + { + // lets us use @bitwarden/common/spec in web tests + "@bitwarden/common/spec": ["../../libs/common/spec"], + ...(compilerOptions?.paths ?? {}), + }, + { + prefix: "/", + }, + ), + }, }; diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 578bc9111cf..7299c8ece22 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -1,8 +1,9 @@ import { DOCUMENT } from "@angular/common"; import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; import * as jq from "jquery"; -import { Subject, filter, firstValueFrom, map, takeUntil, timeout } from "rxjs"; +import { Subject, filter, firstValueFrom, map, takeUntil, timeout, catchError, of } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; @@ -19,7 +20,9 @@ import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broa import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateEventRunnerService } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -30,6 +33,8 @@ import { DialogService, ToastOptions, ToastService } from "@bitwarden/components import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { BiometricStateService } from "@bitwarden/key-management"; +import { flagEnabled } from "../utils/flags"; + import { PolicyListService } from "./admin-console/core/policy-list.service"; import { DisableSendPolicy, @@ -85,7 +90,28 @@ export class AppComponent implements OnDestroy, OnInit { private stateEventRunnerService: StateEventRunnerService, private organizationService: InternalOrganizationServiceAbstraction, private accountService: AccountService, - ) {} + private logService: LogService, + private sdkService: SdkService, + ) { + if (flagEnabled("sdk")) { + // Warn if the SDK for some reason can't be initialized + this.sdkService.supported$ + .pipe( + takeUntilDestroyed(), + catchError(() => { + return of(false); + }), + ) + .subscribe((supported) => { + if (!supported) { + this.logService.debug("SDK is not supported"); + this.sdkService.failedToInitialize().catch(this.logService.error); + } else { + this.logService.debug("SDK is supported"); + } + }); + } + } ngOnInit() { this.i18nService.locale$.pipe(takeUntil(this.destroy$)).subscribe((locale) => { diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 842d31bb105..217b228d323 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -11,6 +11,7 @@ import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/sa import { CLIENT_TYPE, DEFAULT_VAULT_TIMEOUT, + ENV_ADDITIONAL_REGIONS, LOCALES_DIRECTORY, MEMORY_STORAGE, OBSERVABLE_DISK_LOCAL_STORAGE, @@ -42,11 +43,15 @@ import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.ser import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { + EnvironmentService, + Urls, +} from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { AppIdService as DefaultAppIdService } from "@bitwarden/common/platform/services/app-id.service"; @@ -54,6 +59,7 @@ import { MemoryStorageService } from "@bitwarden/common/platform/services/memory // eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; +import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; /* eslint-disable import/no-restricted-paths -- Implementation for memory storage */ import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; @@ -68,6 +74,7 @@ import { VaultTimeout, VaultTimeoutStringType } from "@bitwarden/common/types/va import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { BiometricsService } from "@bitwarden/key-management"; +import { flagEnabled } from "../../utils/flags"; import { PolicyListService } from "../admin-console/core/policy-list.service"; import { WebSetPasswordJitService, @@ -80,10 +87,12 @@ import { I18nService } from "../core/i18n.service"; import { WebBiometricsService } from "../key-management/web-biometric.service"; import { WebEnvironmentService } from "../platform/web-environment.service"; import { WebMigrationRunner } from "../platform/web-migration-runner"; +import { WebSdkClientFactory } from "../platform/web-sdk-client-factory"; import { WebStorageServiceProvider } from "../platform/web-storage-service.provider"; import { EventService } from "./event.service"; import { InitService } from "./init.service"; +import { ENV_URLS } from "./injection-tokens"; import { ModalService } from "./modal.service"; import { RouterService } from "./router.service"; import { WebFileDownloadService } from "./web-file-download.service"; @@ -173,10 +182,14 @@ const safeProviders: SafeProvider[] = [ useClass: WebMigrationRunner, deps: [AbstractStorageService, LogService, MigrationBuilderService, WindowStorageService], }), + safeProvider({ + provide: ENV_URLS, + useValue: process.env.URLS as Urls, + }), safeProvider({ provide: EnvironmentService, useClass: WebEnvironmentService, - deps: [WINDOW, StateProvider, AccountService, Router], + deps: [WINDOW, StateProvider, AccountService, ENV_ADDITIONAL_REGIONS, Router, ENV_URLS], }), safeProvider({ provide: BiometricsService, @@ -236,6 +249,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultCollectionAdminService, deps: [ApiService, CryptoServiceAbstraction, EncryptService, CollectionService], }), + safeProvider({ + provide: SdkClientFactory, + useClass: flagEnabled("sdk") ? WebSdkClientFactory : NoopSdkClientFactory, + deps: [], + }), ]; @NgModule({ diff --git a/apps/web/src/app/core/injection-tokens.ts b/apps/web/src/app/core/injection-tokens.ts new file mode 100644 index 00000000000..9d74a0deb85 --- /dev/null +++ b/apps/web/src/app/core/injection-tokens.ts @@ -0,0 +1,10 @@ +// Put web specific injection tokens here +import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; +import { Urls } from "@bitwarden/common/platform/abstractions/environment.service"; + +/** + * Injection token for injecting the NodeJS process.env urls into services. + * Using an injection token allows services to be tested without needing to + * mock the process.env. + */ +export const ENV_URLS = new SafeInjectionToken("ENV_URLS"); diff --git a/apps/web/src/app/platform/web-environment.service.spec.ts b/apps/web/src/app/platform/web-environment.service.spec.ts new file mode 100644 index 00000000000..14b5e7dcaa0 --- /dev/null +++ b/apps/web/src/app/platform/web-environment.service.spec.ts @@ -0,0 +1,457 @@ +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { Region, Urls } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { PRODUCTION_REGIONS } from "@bitwarden/common/platform/services/default-environment.service"; +import { + FakeAccountService, + FakeStateProvider, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { + WebCloudEnvironment, + WebEnvironmentService, + WebRegionConfig, +} from "./web-environment.service"; + +describe("WebEnvironmentService", () => { + let service: WebEnvironmentService; + + let window: MockProxy; + + let stateProvider: FakeStateProvider; + let accountService: FakeAccountService; + let router: MockProxy; + + const mockUserId = Utils.newGuid() as UserId; + + describe("Production Environment", () => { + describe("US Region", () => { + const mockInitialProdUSUrls = { + base: null, + api: "https://api.bitwarden.com", + identity: "https://identity.bitwarden.com", + icons: "https://icons.bitwarden.net", + webVault: "https://vault.bitwarden.com", + notifications: "https://notifications.bitwarden.com", + events: "https://events.bitwarden.com", + scim: "https://scim.bitwarden.com", + } as Urls; + + const mockProdUSBaseUrl = "https://vault.bitwarden.com"; + + const expectedProdUSUrls: Urls = { + ...mockInitialProdUSUrls, + base: mockProdUSBaseUrl, + }; + + const expectedModifiedScimUrl = expectedProdUSUrls.scim + "/v2"; + const expectedSendUrl = "https://send.bitwarden.com/#"; + + const PROD_US_REGION = PRODUCTION_REGIONS.find((r) => r.key === Region.US); + + const prodUSEnv = new WebCloudEnvironment(PROD_US_REGION, expectedProdUSUrls); + + beforeEach(() => { + window = mock(); + + window.location = { + origin: mockProdUSBaseUrl, + href: mockProdUSBaseUrl + "/#/example", + } as Location; + + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + router = mock(); + + (router as any).url = ""; + + service = new WebEnvironmentService( + window, + stateProvider, + accountService, + [], // no additional region configs required for prod envs + router, + mockInitialProdUSUrls, + ); + }); + + it("initializes the environment with the US production urls", async () => { + const env = await firstValueFrom(service.environment$); + + expect(env).toEqual(prodUSEnv); + + expect(env.getRegion()).toEqual(Region.US); + expect(env.getUrls()).toEqual(expectedProdUSUrls); + expect(env.isCloud()).toBeTruthy(); + + expect(env.getApiUrl()).toEqual(expectedProdUSUrls.api); + expect(env.getIdentityUrl()).toEqual(expectedProdUSUrls.identity); + expect(env.getIconsUrl()).toEqual(expectedProdUSUrls.icons); + expect(env.getWebVaultUrl()).toEqual(expectedProdUSUrls.webVault); + expect(env.getNotificationsUrl()).toEqual(expectedProdUSUrls.notifications); + expect(env.getEventsUrl()).toEqual(expectedProdUSUrls.events); + + expect(env.getScimUrl()).toEqual(expectedModifiedScimUrl); + expect(env.getSendUrl()).toEqual(expectedSendUrl); + + expect(env.getHostname()).toEqual(PROD_US_REGION.domain); + }); + + describe("setEnvironment", () => { + it("throws an error when trying to set the environment to self-hosted", async () => { + await expect(service.setEnvironment(Region.SelfHosted)).rejects.toThrow( + "setEnvironment does not work in web for self-hosted.", + ); + }); + + it("only returns the current env's urls when trying to set the environment to the current region", async () => { + const urls = await service.setEnvironment(Region.US); + expect(urls).toEqual(expectedProdUSUrls); + }); + + it("errors if the selected region is unknown", async () => { + await expect(service.setEnvironment("unknown" as Region)).rejects.toThrow( + "The selected region is not known as an available region.", + ); + }); + + it("sets the window location to a new region's web vault url and preserves any query params", async () => { + const routeAndQueryParams = "/signup?example=1&another=2"; + (router as any).url = routeAndQueryParams; + + const newRegion = Region.EU; + const newRegionConfig = PRODUCTION_REGIONS.find((r) => r.key === newRegion); + + await service.setEnvironment(newRegion); + + expect(window.location.href).toEqual( + newRegionConfig.urls.webVault + "/#" + routeAndQueryParams, + ); + }); + }); + }); + + describe("EU Region", () => { + const mockInitialProdEUUrls = { + base: null, + api: "https://api.bitwarden.eu", + identity: "https://identity.bitwarden.eu", + icons: "https://icons.bitwarden.eu", + webVault: "https://vault.bitwarden.eu", + notifications: "https://notifications.bitwarden.eu", + events: "https://events.bitwarden.eu", + scim: "https://scim.bitwarden.eu", + } as Urls; + + const mockProdEUBaseUrl = "https://vault.bitwarden.eu"; + + const expectedProdEUUrls: Urls = { + ...mockInitialProdEUUrls, + base: mockProdEUBaseUrl, + }; + + const expectedModifiedScimUrl = expectedProdEUUrls.scim + "/v2"; + const expectedSendUrl = expectedProdEUUrls.webVault + "/#/send/"; + + const prodEURegionConfig = PRODUCTION_REGIONS.find((r) => r.key === Region.EU); + + const prodEUEnv = new WebCloudEnvironment(prodEURegionConfig, expectedProdEUUrls); + + beforeEach(() => { + window = mock(); + + window.location = { + origin: mockProdEUBaseUrl, + href: mockProdEUBaseUrl + "/#/example", + } as Location; + + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + router = mock(); + + service = new WebEnvironmentService( + window, + stateProvider, + accountService, + [], // no additional region configs required for prod envs + router, + mockInitialProdEUUrls, + ); + }); + + it("initializes the environment to be the prod EU environment", async () => { + const env = await firstValueFrom(service.environment$); + + expect(env).toEqual(prodEUEnv); + expect(env.getRegion()).toEqual(Region.EU); + expect(env.getUrls()).toEqual(expectedProdEUUrls); + expect(env.isCloud()).toBeTruthy(); + + expect(env.getApiUrl()).toEqual(expectedProdEUUrls.api); + expect(env.getIdentityUrl()).toEqual(expectedProdEUUrls.identity); + expect(env.getIconsUrl()).toEqual(expectedProdEUUrls.icons); + expect(env.getWebVaultUrl()).toEqual(expectedProdEUUrls.webVault); + expect(env.getNotificationsUrl()).toEqual(expectedProdEUUrls.notifications); + expect(env.getEventsUrl()).toEqual(expectedProdEUUrls.events); + + expect(env.getScimUrl()).toEqual(expectedModifiedScimUrl); + expect(env.getSendUrl()).toEqual(expectedSendUrl); + + expect(env.getHostname()).toEqual(prodEURegionConfig.domain); + }); + + describe("setEnvironment", () => { + it("throws an error when trying to set the environment to self-hosted", async () => { + await expect(service.setEnvironment(Region.SelfHosted)).rejects.toThrow( + "setEnvironment does not work in web for self-hosted.", + ); + }); + + it("only returns the current env's urls when trying to set the environment to the current region", async () => { + const urls = await service.setEnvironment(Region.EU); + expect(urls).toEqual(expectedProdEUUrls); + }); + + it("errors if the selected region is unknown", async () => { + await expect(service.setEnvironment("unknown" as Region)).rejects.toThrow( + "The selected region is not known as an available region.", + ); + }); + + it("sets the window location to a new region's web vault url and preserves any query params", async () => { + const routeAndQueryParams = "/signup?example=1&another=2"; + (router as any).url = routeAndQueryParams; + + const newRegion = Region.US; + const newRegionConfig = PRODUCTION_REGIONS.find((r) => r.key === newRegion); + + await service.setEnvironment(newRegion); + + expect(window.location.href).toEqual( + newRegionConfig.urls.webVault + "/#" + routeAndQueryParams, + ); + }); + }); + }); + }); + + describe("QA Environment", () => { + const QA_US_REGION_KEY = "USQA"; + const QA_US_WEB_REGION_CONFIG = { + key: QA_US_REGION_KEY, + domain: "qa.bitwarden.pw", + urls: { + webVault: "https://vault.qa.bitwarden.pw", + }, + } as WebRegionConfig; + + const QA_EU_REGION_KEY = "EUQA"; + const QA_EU_WEB_REGION_CONFIG = { + key: QA_EU_REGION_KEY, + domain: "euqa.bitwarden.pw", + urls: { + webVault: "https://vault.euqa.bitwarden.pw", + }, + } as WebRegionConfig; + + const additionalRegionConfigs: WebRegionConfig[] = [ + QA_US_WEB_REGION_CONFIG, + QA_EU_WEB_REGION_CONFIG, + ]; + + describe("US Region", () => { + const initial_QA_US_Urls = { + icons: "https://icons.qa.bitwarden.pw", + notifications: "https://notifications.qa.bitwarden.pw", + scim: "https://scim.qa.bitwarden.pw", + } as Urls; + + const mock_QA_US_BaseUrl = "https://vault.qa.bitwarden.pw"; + + const expected_QA_US_Urls: Urls = { + ...initial_QA_US_Urls, + base: mock_QA_US_BaseUrl, + }; + + const expectedModifiedScimUrl = expected_QA_US_Urls.scim + "/v2"; + + const expectedSendUrl = QA_US_WEB_REGION_CONFIG.urls.webVault + "/#/send/"; + + const QA_US_Env = new WebCloudEnvironment(QA_US_WEB_REGION_CONFIG, expected_QA_US_Urls); + + beforeEach(() => { + window = mock(); + window.location = { + origin: mock_QA_US_BaseUrl, + href: mock_QA_US_BaseUrl + "/#/example", + } as Location; + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + router = mock(); + (router as any).url = ""; + service = new WebEnvironmentService( + window, + stateProvider, + accountService, + additionalRegionConfigs, + router, + initial_QA_US_Urls, + ); + }); + + it("initializes the environment to be the QA US environment", async () => { + const env = await firstValueFrom(service.environment$); + + expect(env).toEqual(QA_US_Env); + expect(env.getRegion()).toEqual(QA_US_REGION_KEY); + expect(env.getUrls()).toEqual(expected_QA_US_Urls); + expect(env.isCloud()).toBeTruthy(); + + expect(env.getApiUrl()).toEqual(expected_QA_US_Urls.base + "/api"); + expect(env.getIdentityUrl()).toEqual(expected_QA_US_Urls.base + "/identity"); + expect(env.getIconsUrl()).toEqual(expected_QA_US_Urls.icons); + + expect(env.getWebVaultUrl()).toEqual(QA_US_WEB_REGION_CONFIG.urls.webVault); + + expect(env.getNotificationsUrl()).toEqual(expected_QA_US_Urls.notifications); + expect(env.getEventsUrl()).toEqual(expected_QA_US_Urls.base + "/events"); + + expect(env.getScimUrl()).toEqual(expectedModifiedScimUrl); + + expect(env.getSendUrl()).toEqual(expectedSendUrl); + + expect(env.getHostname()).toEqual(QA_US_WEB_REGION_CONFIG.domain); + }); + + describe("setEnvironment", () => { + it("throws an error when trying to set the environment to self-hosted", async () => { + await expect(service.setEnvironment(Region.SelfHosted)).rejects.toThrow( + "setEnvironment does not work in web for self-hosted.", + ); + }); + + it("only returns the current env's urls when trying to set the environment to the current region", async () => { + const urls = await service.setEnvironment(QA_US_REGION_KEY); + expect(urls).toEqual(expected_QA_US_Urls); + }); + + it("errors if the selected region is unknown", async () => { + await expect(service.setEnvironment("unknown" as Region)).rejects.toThrow( + "The selected region is not known as an available region.", + ); + }); + + it("sets the window location to a new region's web vault url and preserves any query params", async () => { + const routeAndQueryParams = "/signup?example=1&another=2"; + (router as any).url = routeAndQueryParams; + + await service.setEnvironment(QA_EU_REGION_KEY); + + expect(window.location.href).toEqual( + QA_EU_WEB_REGION_CONFIG.urls.webVault + "/#" + routeAndQueryParams, + ); + }); + }); + }); + + describe("EU Region", () => { + const initial_QA_EU_Urls = { + icons: "https://icons.euqa.bitwarden.pw", + notifications: "https://notifications.euqa.bitwarden.pw", + scim: "https://scim.euqa.bitwarden.pw", + } as Urls; + + const mock_QA_EU_BaseUrl = "https://vault.euqa.bitwarden.pw"; + + const expected_QA_EU_Urls: Urls = { + ...initial_QA_EU_Urls, + base: mock_QA_EU_BaseUrl, + }; + + const expectedModifiedScimUrl = expected_QA_EU_Urls.scim + "/v2"; + + const expectedSendUrl = QA_EU_WEB_REGION_CONFIG.urls.webVault + "/#/send/"; + + const QA_EU_Env = new WebCloudEnvironment(QA_EU_WEB_REGION_CONFIG, expected_QA_EU_Urls); + + beforeEach(() => { + window = mock(); + window.location = { + origin: mock_QA_EU_BaseUrl, + href: mock_QA_EU_BaseUrl + "/#/example", + } as Location; + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + router = mock(); + (router as any).url = ""; + service = new WebEnvironmentService( + window, + stateProvider, + accountService, + additionalRegionConfigs, + router, + initial_QA_EU_Urls, + ); + }); + + it("initializes the environment to be the QA US environment", async () => { + const env = await firstValueFrom(service.environment$); + + expect(env).toEqual(QA_EU_Env); + expect(env.getRegion()).toEqual(QA_EU_REGION_KEY); + expect(env.getUrls()).toEqual(expected_QA_EU_Urls); + expect(env.isCloud()).toBeTruthy(); + + expect(env.getApiUrl()).toEqual(expected_QA_EU_Urls.base + "/api"); + expect(env.getIdentityUrl()).toEqual(expected_QA_EU_Urls.base + "/identity"); + expect(env.getIconsUrl()).toEqual(expected_QA_EU_Urls.icons); + + expect(env.getWebVaultUrl()).toEqual(QA_EU_WEB_REGION_CONFIG.urls.webVault); + + expect(env.getNotificationsUrl()).toEqual(expected_QA_EU_Urls.notifications); + expect(env.getEventsUrl()).toEqual(expected_QA_EU_Urls.base + "/events"); + + expect(env.getScimUrl()).toEqual(expectedModifiedScimUrl); + + expect(env.getSendUrl()).toEqual(expectedSendUrl); + + expect(env.getHostname()).toEqual(QA_EU_WEB_REGION_CONFIG.domain); + }); + + describe("setEnvironment", () => { + it("throws an error when trying to set the environment to self-hosted", async () => { + await expect(service.setEnvironment(Region.SelfHosted)).rejects.toThrow( + "setEnvironment does not work in web for self-hosted.", + ); + }); + + it("only returns the current env's urls when trying to set the environment to the current region", async () => { + const urls = await service.setEnvironment(QA_EU_REGION_KEY); + expect(urls).toEqual(expected_QA_EU_Urls); + }); + + it("errors if the selected region is unknown", async () => { + await expect(service.setEnvironment("unknown" as Region)).rejects.toThrow( + "The selected region is not known as an available region.", + ); + }); + + it("sets the window location to a new region's web vault url and preserves any query params", async () => { + const routeAndQueryParams = "/signup?example=1&another=2"; + (router as any).url = routeAndQueryParams; + + await service.setEnvironment(QA_US_REGION_KEY); + + expect(window.location.href).toEqual( + QA_US_WEB_REGION_CONFIG.urls.webVault + "/#" + routeAndQueryParams, + ); + }); + }); + }); + }); +}); diff --git a/apps/web/src/app/platform/web-environment.service.ts b/apps/web/src/app/platform/web-environment.service.ts index 9c2afff4a1c..ebddc7491ba 100644 --- a/apps/web/src/app/platform/web-environment.service.ts +++ b/apps/web/src/app/platform/web-environment.service.ts @@ -1,5 +1,5 @@ import { Router } from "@angular/router"; -import { ReplaySubject } from "rxjs"; +import { firstValueFrom, ReplaySubject } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { @@ -8,7 +8,6 @@ import { RegionConfig, Urls, } from "@bitwarden/common/platform/abstractions/environment.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CloudEnvironment, DefaultEnvironmentService, @@ -16,6 +15,12 @@ import { } from "@bitwarden/common/platform/services/default-environment.service"; import { StateProvider } from "@bitwarden/common/platform/state"; +export type WebRegionConfig = RegionConfig & { + key: Region | string; // strings are used for custom environments + domain: string; + urls: Urls; +}; + /** * Web specific environment service. Ensures that the urls are set from the window location. */ @@ -24,23 +29,30 @@ export class WebEnvironmentService extends DefaultEnvironmentService { private win: Window, stateProvider: StateProvider, accountService: AccountService, + additionalRegionConfigs: WebRegionConfig[] = [], private router: Router, + private envUrls: Urls, ) { - super(stateProvider, accountService); + super(stateProvider, accountService, additionalRegionConfigs); // The web vault always uses the current location as the base url - const urls = process.env.URLS as Urls; - urls.base ??= this.win.location.origin; + envUrls.base ??= this.win.location.origin; // Find the region - const domain = Utils.getDomain(this.win.location.href); - const region = this.availableRegions().find((r) => Utils.getDomain(r.urls.webVault) === domain); + const currentHostname = new URL(this.win.location.href).hostname; + const availableRegions = this.availableRegions(); + const region = availableRegions.find((r) => { + // We must use hostname as our QA envs use the same + // domain (bitwarden.pw) but different subdomains (qa and euqa) + const webVaultHostname = new URL(r.urls.webVault).hostname; + return webVaultHostname === currentHostname; + }); let environment: Environment; if (region) { - environment = new WebCloudEnvironment(region, urls); + environment = new WebCloudEnvironment(region, envUrls); } else { - environment = new SelfHostedEnvironment(urls); + environment = new SelfHostedEnvironment(envUrls); } // Override the environment observable with a replay subject @@ -50,37 +62,45 @@ export class WebEnvironmentService extends DefaultEnvironmentService { } // Web setting env means navigating to a new location - setEnvironment(region: Region, urls?: Urls): Promise { + async setEnvironment(region: Region | string, urls?: Urls): Promise { if (region === Region.SelfHosted) { throw new Error("setEnvironment does not work in web for self-hosted."); } - const currentDomain = Utils.getDomain(this.win.location.href); - const currentRegion = this.availableRegions().find( - (r) => Utils.getDomain(r.urls.webVault) === currentDomain, - ); + // Find the region + const currentHostname = new URL(this.win.location.href).hostname; + const availableRegions = this.availableRegions(); + const currentRegionConfig = availableRegions.find((r) => { + // We must use hostname as our QA envs use the same + // domain (bitwarden.pw) but different subdomains (qa and euqa) + const webVaultHostname = new URL(r.urls.webVault).hostname; + return webVaultHostname === currentHostname; + }); - if (currentRegion.key === region) { - // They have selected the current region, nothing to do - return Promise.resolve(currentRegion.urls); + if (currentRegionConfig.key === region) { + // They have selected the current region, return the current env urls + // We can't return the region urls because the env base url is modified + // in the constructor to match the current window.location.origin. + const currentEnv = await firstValueFrom(this.environment$); + return currentEnv.getUrls(); } - const chosenRegion = this.availableRegions().find((r) => r.key === region); + const chosenRegionConfig = this.availableRegions().find((r) => r.key === region); - if (chosenRegion == null) { + if (chosenRegionConfig == null) { throw new Error("The selected region is not known as an available region."); } // Preserve the current in app route + params in the new location const routeAndParams = `/#${this.router.url}`; - this.win.location.href = chosenRegion.urls.webVault + routeAndParams; + this.win.location.href = chosenRegionConfig.urls.webVault + routeAndParams; // This return shouldn't matter as we are about to leave the current window - return Promise.resolve(chosenRegion.urls); + return chosenRegionConfig.urls; } } -class WebCloudEnvironment extends CloudEnvironment { +export class WebCloudEnvironment extends CloudEnvironment { constructor(config: RegionConfig, urls: Urls) { super(config); // We override the urls to avoid CORS issues diff --git a/apps/web/src/app/platform/web-sdk-client-factory.ts b/apps/web/src/app/platform/web-sdk-client-factory.ts new file mode 100644 index 00000000000..2ebb2bcc10f --- /dev/null +++ b/apps/web/src/app/platform/web-sdk-client-factory.ts @@ -0,0 +1,42 @@ +import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; +import * as sdk from "@bitwarden/sdk-internal"; + +/** + * SDK client factory with a js fallback for when WASM is not supported. + */ +export class WebSdkClientFactory implements SdkClientFactory { + async createSdkClient( + ...args: ConstructorParameters + ): Promise { + const module = await load(); + + (sdk as any).init(module); + + return Promise.resolve(new sdk.BitwardenClient(...args)); + } +} + +// https://stackoverflow.com/a/47880734 +const supported = (() => { + try { + if (typeof WebAssembly === "object" && typeof WebAssembly.instantiate === "function") { + const module = new WebAssembly.Module( + Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00), + ); + if (module instanceof WebAssembly.Module) { + return new WebAssembly.Instance(module) instanceof WebAssembly.Instance; + } + } + } catch (e) { + // ignore + } + return false; +})(); + +async function load() { + if (supported) { + return await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm"); + } else { + return await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm.js"); + } +} diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index 521665496ad..740264713ca 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -65,7 +65,7 @@ > {{ "cancel" | i18n }} -
+
diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index d3d0703605f..c1878e2dcb7 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -179,6 +179,15 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return this.cipher?.edit ?? false; } + protected get showDelete() { + // Don't show the delete button when cloning a cipher + if (this.params.mode == "form" && this.formConfig.mode === "clone") { + return false; + } + // Never show the delete button for new ciphers + return this.cipher != null; + } + protected get showCipherView() { return this.cipher != undefined && (this.params.mode === "view" || this.loadingForm); } @@ -332,8 +341,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { }; cancel = async () => { - // We're in View mode, or we don't have a cipher, close the dialog. - if (this.params.mode === "view" || this.cipher == null) { + // We're in View mode, we don't have a cipher, or we were cloning, close the dialog. + if (this.params.mode === "view" || this.cipher == null || this.formConfig.mode === "clone") { this.dialogRef.close(this._cipherModified ? VaultItemDialogResult.Saved : undefined); return; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index 99516907bbb..55fa4bcb4e9 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -16,13 +16,38 @@ "all" | i18n }} - {{ "name" | i18n }} + + + {{ "name" | i18n }} + + + + {{ "name" | i18n }} + {{ "owner" | i18n }} {{ "collections" | i18n }} - {{ "groups" | i18n }} - + + {{ "groups" | i18n }} + + {{ "permission" | i18n }} diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 4f7abfb99e3..9a7f3da564b 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -1,14 +1,19 @@ import { SelectionModel } from "@angular/cdk/collections"; -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { Component, EventEmitter, inject, Input, Output } from "@angular/core"; -import { Unassigned } from "@bitwarden/admin-console/common"; +import { CollectionAdminView, Unassigned } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { TableDataSource } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; +import { + CollectionPermission, + convertToPermission, +} from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models"; import { VaultItem } from "./vault-item"; import { VaultItemEvent } from "./vault-item-event"; @@ -25,6 +30,7 @@ const MaxSelectionCount = 500; // changeDetection: ChangeDetectionStrategy.OnPush, }) export class VaultItemsComponent { + protected i18nService = inject(I18nService); protected RowHeight = RowHeight; @Input() disabled: boolean; @@ -197,7 +203,7 @@ export class VaultItemsComponent { private refreshItems() { const collections: VaultItem[] = this.collections.map((collection) => ({ collection })); const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher })); - const items: VaultItem[] = [].concat(collections).concat(ciphers); + let items: VaultItem[] = [].concat(collections).concat(ciphers); this.selection.clear(); @@ -208,6 +214,11 @@ export class VaultItemsComponent { (item.collection !== undefined && item.collection.id !== Unassigned), ); + // Apply sorting only for organization vault + if (this.showAdminActions) { + items = items.sort(this.sortByGroups); + } + this.dataSource.data = items; } @@ -293,6 +304,112 @@ export class VaultItemsComponent { return false; } + /** + * Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name. + */ + protected sortByName = (a: VaultItem, b: VaultItem) => { + const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; + + // First, sort collections before ciphers + if (a.collection && !b.collection) { + return -1; + } + if (!a.collection && b.collection) { + return 1; + } + + return getName(a).localeCompare(getName(b)); + }; + + /** + * Sorts VaultItems based on group names + */ + protected sortByGroups = (a: VaultItem, b: VaultItem): number => { + const getGroupNames = (item: VaultItem): string => { + if (item.collection instanceof CollectionAdminView) { + return item.collection.groups + .map((group) => this.getGroupName(group.id)) + .filter(Boolean) + .join(","); + } + + return ""; + }; + + const aGroupNames = getGroupNames(a); + const bGroupNames = getGroupNames(b); + + if (aGroupNames.length !== bGroupNames.length) { + return bGroupNames.length - aGroupNames.length; + } + + return aGroupNames.localeCompare(bGroupNames); + }; + + /** + * Sorts VaultItems based on their permissions, with higher permissions taking precedence. + * If permissions are equal, it falls back to sorting by name. + */ + protected sortByPermissions = (a: VaultItem, b: VaultItem): number => { + const getPermissionPriority = (item: VaultItem): number => { + if (item.collection instanceof CollectionAdminView) { + const permission = this.getCollectionPermission(item.collection); + + switch (permission) { + case CollectionPermission.Manage: + return 5; + case CollectionPermission.Edit: + return 4; + case CollectionPermission.EditExceptPass: + return 3; + case CollectionPermission.View: + return 2; + case CollectionPermission.ViewExceptPass: + return 1; + case "NoAccess": + return 0; + } + } + + return -1; + }; + + const priorityA = getPermissionPriority(a); + const priorityB = getPermissionPriority(b); + + // Higher priority first + if (priorityA !== priorityB) { + return priorityB - priorityA; + } + + return this.sortByName(a, b); + }; + + /** + * Default sorting function for vault items. + * Sorts by: 1. Collections before ciphers + * 2. Highest permission first + * 3. Alphabetical order of collections and ciphers + */ + private defaultSort = (a: VaultItem, b: VaultItem) => { + // First, sort collections before ciphers + if (a.collection && !b.collection) { + return -1; + } + if (!a.collection && b.collection) { + return 1; + } + + // Next, sort by permissions + const permissionSort = this.sortByPermissions(a, b); + if (permissionSort !== 0) { + return permissionSort; + } + + // Finally, sort by name + return this.sortByName(a, b); + }; + private hasPersonalItems(): boolean { return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null); } @@ -306,4 +423,24 @@ export class VaultItemsComponent { private getUniqueOrganizationIds(): Set { return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? [])); } + + private getGroupName(groupId: string): string | undefined { + return this.allGroups.find((g) => g.id === groupId)?.name; + } + + private getCollectionPermission( + collection: CollectionAdminView, + ): CollectionPermission | "NoAccess" { + const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); + + if (collection.id == Unassigned && organization?.canEditUnassignedCiphers) { + return CollectionPermission.Edit; + } + + if (collection.assigned) { + return convertToPermission(collection); + } + + return "NoAccess"; + } } diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index 1eea053d1f0..a52530dde1a 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -1249,6 +1249,8 @@ export class VaultComponent implements OnInit, OnDestroy { organizationId: this.organization?.id as OrganizationId, availableCollections, activeCollection: this.activeFilter?.selectedCollectionNode?.node, + isSingleCipherAdmin: + items.length === 1 && (this.organization?.canEditAllCiphers || items[0].isUnassigned), }, }); diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index fee60264ea4..df325015aad 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -78,7 +78,7 @@ const moduleRules = [ loader: "@ngtools/webpack", }, { - test: /\.wasm$/, + test: /argon2(-simd)?\.wasm$/, loader: "base64-loader", type: "javascript/auto", }, @@ -324,6 +324,7 @@ const webpackConfig = { mode: NODE_ENV, devtool: "source-map", devServer: devServer, + target: "web", entry: { "app/polyfills": "./src/polyfills.ts", "app/main": "./src/main.ts", @@ -383,9 +384,12 @@ const webpackConfig = { clean: true, }, module: { - noParse: /\.wasm$/, + noParse: /argon2(-simd)?\.wasm$/, rules: moduleRules, }, + experiments: { + asyncWebAssembly: true, + }, plugins: plugins, }; diff --git a/bitwarden_license/bit-cli/jest.config.js b/bitwarden_license/bit-cli/jest.config.js index 92be98cc561..30c9784c326 100644 --- a/bitwarden_license/bit-cli/jest.config.js +++ b/bitwarden_license/bit-cli/jest.config.js @@ -10,7 +10,11 @@ module.exports = { preset: "ts-jest", testEnvironment: "node", setupFilesAfterEnv: ["/../../apps/cli/test.setup.ts"], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { - prefix: "/", - }), + moduleNameMapper: { + "@bitwarden/common/platform/services/sdk/default-sdk-client-factory": + "/../../libs/common/spec/jest-sdk-client-factory", + ...pathsToModuleNameMapper(compilerOptions?.paths || {}, { + prefix: "/", + }), + }, }; diff --git a/bitwarden_license/bit-cli/tsconfig.json b/bitwarden_license/bit-cli/tsconfig.json index bb9986e6c9d..9440a03375a 100644 --- a/bitwarden_license/bit-cli/tsconfig.json +++ b/bitwarden_license/bit-cli/tsconfig.json @@ -3,7 +3,7 @@ "pretty": true, "moduleResolution": "node", "target": "ES2016", - "module": "es6", + "module": "ES2020", "noImplicitAny": true, "allowSyntheticDefaultImports": true, "emitDecoratorMetadata": true, diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 40405b062c6..572d26ffc03 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -3,6 +3,7 @@ import { Observable, Subject } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; import { ClientType } from "@bitwarden/common/enums"; +import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; import { AbstractStorageService, ObservableStorageService, @@ -58,3 +59,12 @@ export const CLIENT_TYPE = new SafeInjectionToken("CLIENT_TYPE"); export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() => void>( "REFRESH_ACCESS_TOKEN_ERROR_CALLBACK", ); + +/** + * Injection token for injecting the NodeJS process.env additional regions into services. + * Using an injection token allows services to be tested without needing to + * mock the process.env. + */ +export const ENV_ADDITIONAL_REGIONS = new SafeInjectionToken( + "ENV_ADDITIONAL_REGIONS", +); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 512f0730f8f..41b24a4a549 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -141,13 +141,18 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { + EnvironmentService, + RegionConfig, +} from "@bitwarden/common/platform/abstractions/environment.service"; import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -176,6 +181,7 @@ import { KeyGenerationService } from "@bitwarden/common/platform/services/key-ge import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service"; +import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; @@ -298,6 +304,7 @@ import { INTRAPROCESS_MESSAGING_SUBJECT, CLIENT_TYPE, REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, + ENV_ADDITIONAL_REGIONS, } from "./injection-tokens"; import { ModalService } from "./modal.service"; @@ -530,10 +537,14 @@ const safeProviders: SafeProvider[] = [ useClass: CollectionService, deps: [CryptoServiceAbstraction, EncryptService, I18nServiceAbstraction, StateProvider], }), + safeProvider({ + provide: ENV_ADDITIONAL_REGIONS, + useValue: process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[], + }), safeProvider({ provide: EnvironmentService, useClass: DefaultEnvironmentService, - deps: [StateProvider, AccountServiceAbstraction], + deps: [StateProvider, AccountServiceAbstraction, ENV_ADDITIONAL_REGIONS], }), safeProvider({ provide: InternalUserDecryptionOptionsServiceAbstraction, @@ -1316,6 +1327,16 @@ const safeProviders: SafeProvider[] = [ useExisting: NoopViewCacheService, deps: [], }), + safeProvider({ + provide: SdkService, + useClass: DefaultSdkService, + deps: [ + SdkClientFactory, + EnvironmentService, + PlatformUtilsServiceAbstraction, + ApiServiceAbstraction, + ], + }), ]; @NgModule({ diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 960a226b1cf..dc782e6e3a9 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -605,6 +605,10 @@ export class AddEditComponent implements OnInit, OnDestroy { this.collections = this.writeableCollections?.filter( (c) => c.organizationId === this.cipher.organizationId, ); + // If there's only one collection, check it by default + if (this.collections.length === 1) { + (this.collections[0] as any).checked = true; + } const org = await this.organizationService.get(this.cipher.organizationId); if (org != null) { this.cipher.organizationUseTotp = org.useTotp; diff --git a/libs/common/spec/jest-sdk-client-factory.ts b/libs/common/spec/jest-sdk-client-factory.ts new file mode 100644 index 00000000000..ff120ccd787 --- /dev/null +++ b/libs/common/spec/jest-sdk-client-factory.ts @@ -0,0 +1,9 @@ +import { ClientSettings, LogLevel, BitwardenClient } from "@bitwarden/sdk-internal"; + +import { SdkClientFactory } from "../src/platform/abstractions/sdk/sdk-client-factory"; + +export class DefaultSdkClientFactory implements SdkClientFactory { + createSdkClient(settings?: ClientSettings, log_level?: LogLevel): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/libs/common/src/platform/abstractions/sdk/sdk-client-factory.ts b/libs/common/src/platform/abstractions/sdk/sdk-client-factory.ts new file mode 100644 index 00000000000..d684561dacd --- /dev/null +++ b/libs/common/src/platform/abstractions/sdk/sdk-client-factory.ts @@ -0,0 +1,10 @@ +import type { BitwardenClient } from "@bitwarden/sdk-internal"; + +/** + * Factory for creating SDK clients. + */ +export abstract class SdkClientFactory { + abstract createSdkClient( + ...args: ConstructorParameters + ): Promise; +} diff --git a/libs/common/src/platform/abstractions/sdk/sdk.service.ts b/libs/common/src/platform/abstractions/sdk/sdk.service.ts new file mode 100644 index 00000000000..360f2e91a76 --- /dev/null +++ b/libs/common/src/platform/abstractions/sdk/sdk.service.ts @@ -0,0 +1,10 @@ +import { Observable } from "rxjs"; + +import { BitwardenClient } from "@bitwarden/sdk-internal"; + +export abstract class SdkService { + client$: Observable; + supported$: Observable; + + abstract failedToInitialize(): Promise; +} diff --git a/libs/common/src/platform/misc/flags.ts b/libs/common/src/platform/misc/flags.ts index 3a305676812..b3269c8f4e8 100644 --- a/libs/common/src/platform/misc/flags.ts +++ b/libs/common/src/platform/misc/flags.ts @@ -2,6 +2,7 @@ // eslint-disable-next-line @typescript-eslint/ban-types export type SharedFlags = { showPasswordless?: boolean; + sdk?: boolean; }; // required to avoid linting errors when there are no flags @@ -28,7 +29,7 @@ function getFlags(envFlags: string | T): T { * @returns The value of the flag */ export function flagEnabled(flag: keyof Flags): boolean { - const flags = getFlags(process.env.FLAGS); + const flags = getFlags(process.env.FLAGS) ?? ({} as Flags); return flags[flag] == null || !!flags[flag]; } diff --git a/libs/common/src/platform/services/default-environment.service.ts b/libs/common/src/platform/services/default-environment.service.ts index 97f084d80f3..8ed673d066e 100644 --- a/libs/common/src/platform/services/default-environment.service.ts +++ b/libs/common/src/platform/services/default-environment.service.ts @@ -136,6 +136,7 @@ export class DefaultEnvironmentService implements EnvironmentService { constructor( private stateProvider: StateProvider, private accountService: AccountService, + private additionalRegionConfigs: RegionConfig[] = [], ) { this.globalState = this.stateProvider.getGlobal(GLOBAL_ENVIRONMENT_KEY); this.globalCloudRegionState = this.stateProvider.getGlobal(GLOBAL_CLOUD_REGION_KEY); @@ -177,8 +178,7 @@ export class DefaultEnvironmentService implements EnvironmentService { } availableRegions(): RegionConfig[] { - const additionalRegions = (process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[]) ?? []; - return PRODUCTION_REGIONS.concat(additionalRegions); + return PRODUCTION_REGIONS.concat(this.additionalRegionConfigs); } /** diff --git a/libs/common/src/platform/services/sdk/default-sdk-client-factory.ts b/libs/common/src/platform/services/sdk/default-sdk-client-factory.ts new file mode 100644 index 00000000000..8e99af2efed --- /dev/null +++ b/libs/common/src/platform/services/sdk/default-sdk-client-factory.ts @@ -0,0 +1,19 @@ +import * as sdk from "@bitwarden/sdk-internal"; +import * as module from "@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm"; + +import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory"; + +/** + * Directly imports the Bitwarden SDK and initializes it. + * + * **Warning**: This requires WASM support and will fail if the environment does not support it. + */ +export class DefaultSdkClientFactory implements SdkClientFactory { + async createSdkClient( + ...args: ConstructorParameters + ): Promise { + (sdk as any).init(module); + + return Promise.resolve(new sdk.BitwardenClient(...args)); + } +} diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts new file mode 100644 index 00000000000..d4a9cfeb7ed --- /dev/null +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -0,0 +1,111 @@ +import { concatMap, firstValueFrom, shareReplay } from "rxjs"; + +import { LogLevel, DeviceType as SdkDeviceType } from "@bitwarden/sdk-internal"; + +import { ApiService } from "../../../abstractions/api.service"; +import { DeviceType } from "../../../enums/device-type.enum"; +import { EnvironmentService } from "../../abstractions/environment.service"; +import { PlatformUtilsService } from "../../abstractions/platform-utils.service"; +import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory"; +import { SdkService } from "../../abstractions/sdk/sdk.service"; + +export class DefaultSdkService implements SdkService { + client$ = this.environmentService.environment$.pipe( + concatMap(async (env) => { + const settings = { + apiUrl: env.getApiUrl(), + identityUrl: env.getIdentityUrl(), + deviceType: this.toDevice(this.platformUtilsService.getDevice()), + userAgent: this.userAgent ?? navigator.userAgent, + }; + + return await this.sdkClientFactory.createSdkClient(settings, LogLevel.Info); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + supported$ = this.client$.pipe( + concatMap(async (client) => { + return client.echo("bitwarden wasm!") === "bitwarden wasm!"; + }), + ); + + constructor( + private sdkClientFactory: SdkClientFactory, + private environmentService: EnvironmentService, + private platformUtilsService: PlatformUtilsService, + private apiService: ApiService, // Yes we shouldn't import ApiService, but it's temporary + private userAgent: string = null, + ) {} + + async failedToInitialize(): Promise { + // Only log on cloud instances + if ( + this.platformUtilsService.isDev() || + !(await firstValueFrom(this.environmentService.environment$)).isCloud + ) { + return; + } + + return this.apiService.send("POST", "/wasm-debug", null, false, false, null, (headers) => { + headers.append("SDK-Version", "1.0.0"); + }); + } + + private toDevice(device: DeviceType): SdkDeviceType { + switch (device) { + case DeviceType.Android: + return "Android"; + case DeviceType.iOS: + return "iOS"; + case DeviceType.ChromeExtension: + return "ChromeExtension"; + case DeviceType.FirefoxExtension: + return "FirefoxExtension"; + case DeviceType.OperaExtension: + return "OperaExtension"; + case DeviceType.EdgeExtension: + return "EdgeExtension"; + case DeviceType.WindowsDesktop: + return "WindowsDesktop"; + case DeviceType.MacOsDesktop: + return "MacOsDesktop"; + case DeviceType.LinuxDesktop: + return "LinuxDesktop"; + case DeviceType.ChromeBrowser: + return "ChromeBrowser"; + case DeviceType.FirefoxBrowser: + return "FirefoxBrowser"; + case DeviceType.OperaBrowser: + return "OperaBrowser"; + case DeviceType.EdgeBrowser: + return "EdgeBrowser"; + case DeviceType.IEBrowser: + return "IEBrowser"; + case DeviceType.UnknownBrowser: + return "UnknownBrowser"; + case DeviceType.AndroidAmazon: + return "AndroidAmazon"; + case DeviceType.UWP: + return "UWP"; + case DeviceType.SafariBrowser: + return "SafariBrowser"; + case DeviceType.VivaldiBrowser: + return "VivaldiBrowser"; + case DeviceType.VivaldiExtension: + return "VivaldiExtension"; + case DeviceType.SafariExtension: + return "SafariExtension"; + case DeviceType.Server: + return "Server"; + case DeviceType.WindowsCLI: + return "WindowsCLI"; + case DeviceType.MacOsCLI: + return "MacOsCLI"; + case DeviceType.LinuxCLI: + return "LinuxCLI"; + default: + return "SDK"; + } + } +} diff --git a/libs/common/src/platform/services/sdk/noop-sdk-client-factory.ts b/libs/common/src/platform/services/sdk/noop-sdk-client-factory.ts new file mode 100644 index 00000000000..d7eab7e8dc9 --- /dev/null +++ b/libs/common/src/platform/services/sdk/noop-sdk-client-factory.ts @@ -0,0 +1,16 @@ +import type { BitwardenClient } from "@bitwarden/sdk-internal"; + +import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory"; + +/** + * Noop SDK client factory. + * + * Used during SDK rollout to prevent bundling the SDK with some applications. + */ +export class NoopSdkClientFactory implements SdkClientFactory { + createSdkClient( + ...args: ConstructorParameters + ): Promise { + return Promise.reject(new Error("SDK not available")); + } +} diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 061bd5cedb5..e82c07653cd 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -113,6 +113,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider Promise; + + /** + * Save the collections for a cipher with the server as an admin. + * Used for Unassigned ciphers or when the user only has admin access to the cipher (not assigned normally). + * @param cipher + */ + saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise; /** * Bulk update collections for many ciphers with the server * @param orgId diff --git a/libs/common/src/vault/abstractions/collection.service.ts b/libs/common/src/vault/abstractions/collection.service.ts index 81ae76729a2..1f3e95a019f 100644 --- a/libs/common/src/vault/abstractions/collection.service.ts +++ b/libs/common/src/vault/abstractions/collection.service.ts @@ -8,6 +8,7 @@ import { TreeNode } from "../models/domain/tree-node"; import { CollectionView } from "../models/view/collection.view"; export abstract class CollectionService { + encryptedCollections$: Observable; decryptedCollections$: Observable; clearActiveUserCache: () => Promise; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 9761387284f..a06ca4d793d 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -858,6 +858,11 @@ export class CipherService implements CipherServiceAbstraction { return new Cipher(updated[cipher.id as CipherId], cipher.localData); } + async saveCollectionsWithServerAdmin(cipher: Cipher): Promise { + const request = new CipherCollectionsRequest(cipher.collectionIds); + await this.apiService.putCipherCollectionsAdmin(cipher.id, request); + } + /** * Bulk update collections for many ciphers with the server * @param orgId diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts index 673f7326c16..cf298b19ac7 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts @@ -96,6 +96,17 @@ describe("UriOptionComponent", () => { expect(component["uriForm"].enabled).toBe(false); }); + it("should update form when `writeValue` is invoked", () => { + expect(component["uriForm"].value).toEqual({ uri: null, matchDetection: null }); + + component.writeValue({ uri: "example.com", matchDetection: UriMatchStrategy.Exact }); + + expect(component["uriForm"].value).toEqual({ + uri: "example.com", + matchDetection: UriMatchStrategy.Exact, + }); + }); + describe("match detection", () => { it("should hide the match detection select by default", () => { fixture.detectChanges(); diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts index 26f59ba9c16..a7741ae1bc6 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts @@ -149,12 +149,12 @@ export class UriOptionComponent implements ControlValueAccessor { } // NG_VALUE_ACCESSOR implementation - writeValue(value: any): void { + writeValue(value: { uri: string; matchDetection: UriMatchStrategySetting | null }): void { if (value) { this.uriForm.setValue( { uri: value.uri ?? "", - matchDetection: value.match ?? null, + matchDetection: value.matchDetection ?? null, }, { emitEvent: false }, ); diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts index 4171f153ecc..977cee8156d 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts @@ -1,5 +1,5 @@ import { inject, Injectable } from "@angular/core"; -import { combineLatest, firstValueFrom, map } from "rxjs"; +import { combineLatest, filter, firstValueFrom, map, switchMap } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -39,9 +39,21 @@ export class DefaultCipherFormConfigService implements CipherFormConfigService { await firstValueFrom( combineLatest([ this.organizations$, - this.collectionService.decryptedCollections$, + this.collectionService.encryptedCollections$.pipe( + switchMap((c) => + this.collectionService.decryptedCollections$.pipe( + filter((d) => d.length === c.length), // Ensure all collections have been decrypted + ), + ), + ), this.allowPersonalOwnership$, - this.folderService.folderViews$, + this.folderService.folders$.pipe( + switchMap((f) => + this.folderService.folderViews$.pipe( + filter((d) => d.length - 1 === f.length), // -1 for "No Folder" in folderViews$ + ), + ), + ), this.getCipher(cipherId), ]), ); diff --git a/libs/vault/src/components/assign-collections.component.ts b/libs/vault/src/components/assign-collections.component.ts index db4d61691c9..884a79bf1d0 100644 --- a/libs/vault/src/components/assign-collections.component.ts +++ b/libs/vault/src/components/assign-collections.component.ts @@ -64,6 +64,15 @@ export interface CollectionAssignmentParams { * removed from the ciphers upon submission. */ activeCollection?: CollectionView; + + /** + * Flag indicating if the user is performing the action as an admin on a SINGLE cipher. When true, + * the `/admin` endpoint will be used to update the cipher's collections. Required when updating + * ciphers an Admin does not normally have access to or for Unassigned ciphers. + * + * The bulk method already handles admin actions internally. + */ + isSingleCipherAdmin?: boolean; } export enum CollectionAssignmentResult { @@ -463,6 +472,10 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI const { collections } = this.formGroup.getRawValue(); cipherView.collectionIds = collections.map((i) => i.id as CollectionId); const cipher = await this.cipherService.encrypt(cipherView, this.activeUserId); - await this.cipherService.saveCollectionsWithServer(cipher); + if (this.params.isSingleCipherAdmin) { + await this.cipherService.saveCollectionsWithServerAdmin(cipher); + } else { + await this.cipherService.saveCollectionsWithServer(cipher); + } } } diff --git a/package-lock.json b/package-lock.json index f5e233c2328..7ef9cf23d9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@angular/platform-browser": "16.2.12", "@angular/platform-browser-dynamic": "16.2.12", "@angular/router": "16.2.12", + "@bitwarden/sdk-internal": "0.1.3", "@electron/fuses": "1.8.0", "@koa/multer": "3.0.2", "@koa/router": "12.0.1", @@ -4625,6 +4626,11 @@ "resolved": "libs/platform", "link": true }, + "node_modules/@bitwarden/sdk-internal": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.1.3.tgz", + "integrity": "sha512-zk9DyYMjylVLdljeLn3OLBcD939Hg/qMNJ2FxbyjiSKtcOcgglXgYmbcS01NRFFfM9REbn+j+2fWbQo6N+8SHw==" + }, "node_modules/@bitwarden/vault": { "resolved": "libs/vault", "link": true diff --git a/package.json b/package.json index ed4e95c0127..2926fd095fc 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,7 @@ "@angular/platform-browser": "16.2.12", "@angular/platform-browser-dynamic": "16.2.12", "@angular/router": "16.2.12", + "@bitwarden/sdk-internal": "0.1.3", "@electron/fuses": "1.8.0", "@koa/multer": "3.0.2", "@koa/router": "12.0.1",