diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 1c0b178895..bd62b825e7 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1120,9 +1120,6 @@ "commandLockVaultDesc": { "message": "Lock the vault" }, - "privateModeWarning": { - "message": "Private mode support is experimental and some features are limited." - }, "customFields": { "message": "Custom fields" }, diff --git a/apps/browser/src/auth/popup/lock.component.html b/apps/browser/src/auth/popup/lock.component.html index 9892503a7b..5ea839470b 100644 --- a/apps/browser/src/auth/popup/lock.component.html +++ b/apps/browser/src/auth/popup/lock.component.html @@ -89,7 +89,6 @@

- {{ biometricError }}

{{ "awaitDesktop" | i18n }} diff --git a/apps/browser/src/auth/popup/login.component.html b/apps/browser/src/auth/popup/login.component.html index b24a25a0f1..7a4211a5cc 100644 --- a/apps/browser/src/auth/popup/login.component.html +++ b/apps/browser/src/auth/popup/login.component.html @@ -57,7 +57,6 @@ -

+ + + + + + + + + + + + + + `, standalone: true, + imports: [CommonModule, ItemModule, BadgeModule, IconButtonModule], }) -class VaultComponent {} +class VaultComponent { + protected data = Array.from(Array(20).keys()); +} @Component({ selector: "generator-placeholder", diff --git a/apps/browser/src/platform/services/default-browser-state.service.ts b/apps/browser/src/platform/services/default-browser-state.service.ts index f717ab96d8..b0b9e3748f 100644 --- a/apps/browser/src/platform/services/default-browser-state.service.ts +++ b/apps/browser/src/platform/services/default-browser-state.service.ts @@ -62,15 +62,6 @@ export class DefaultBrowserStateService await super.addAccount(account); } - async getIsAuthenticated(options?: StorageOptions): Promise { - // Firefox Private Mode can clash with non-Private Mode because they both read from the same onDiskOptions - // Check that there is an account in memory before considering the user authenticated - return ( - (await super.getIsAuthenticated(options)) && - (await this.getAccount(await this.defaultInMemoryOptions())) != null - ); - } - // Overriding the base class to prevent deleting the cache on save. We register a storage listener // to delete the cache in the constructor above. protected override async saveAccountToDisk( diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 2fb582d693..0862c2da52 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -70,7 +70,6 @@ import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit. import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; import { PopOutComponent } from "./components/pop-out.component"; -import { PrivateModeWarningComponent } from "./components/private-mode-warning.component"; import { UserVerificationComponent } from "./components/user-verification.component"; import { ServicesModule } from "./services/services.module"; import { ExcludedDomainsComponent } from "./settings/excluded-domains.component"; @@ -150,7 +149,6 @@ import "../platform/popup/locales"; PasswordHistoryComponent, PopOutComponent, PremiumComponent, - PrivateModeWarningComponent, RegisterComponent, SendAddEditComponent, SendGroupingsComponent, diff --git a/apps/browser/src/popup/components/private-mode-warning.component.html b/apps/browser/src/popup/components/private-mode-warning.component.html deleted file mode 100644 index 9cd53ea1be..0000000000 --- a/apps/browser/src/popup/components/private-mode-warning.component.html +++ /dev/null @@ -1,6 +0,0 @@ - - {{ "privateModeWarning" | i18n }} - {{ - "learnMore" | i18n - }} - diff --git a/apps/browser/src/popup/components/private-mode-warning.component.ts b/apps/browser/src/popup/components/private-mode-warning.component.ts deleted file mode 100644 index ff6292bdbf..0000000000 --- a/apps/browser/src/popup/components/private-mode-warning.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Component, OnInit } from "@angular/core"; - -import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; - -@Component({ - selector: "app-private-mode-warning", - templateUrl: "private-mode-warning.component.html", -}) -export class PrivateModeWarningComponent implements OnInit { - showWarning = false; - - ngOnInit() { - this.showWarning = BrowserPopupUtils.inPrivateMode(); - } -} diff --git a/apps/browser/src/popup/scss/pages.scss b/apps/browser/src/popup/scss/pages.scss index 3c651682c1..3ae3647299 100644 --- a/apps/browser/src/popup/scss/pages.scss +++ b/apps/browser/src/popup/scss/pages.scss @@ -111,11 +111,6 @@ app-home { } } -.app-private-mode-warning { - display: block; - padding-top: 1rem; -} - body.body-sm, body.body-xs { app-home { diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index bec278aeeb..163b2f1edb 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -28,7 +28,9 @@ import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/a import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; +import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; @@ -53,6 +55,7 @@ import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt. import { EnvironmentService } 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 { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; @@ -61,6 +64,7 @@ import { AbstractStorageService, ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; +import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency injection @@ -100,6 +104,7 @@ import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; import { BrowserFileDownloadService } from "../../platform/popup/services/browser-file-download.service"; import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service"; import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service"; +import { BrowserCryptoService } from "../../platform/services/browser-crypto.service"; import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service"; import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service"; import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; @@ -125,13 +130,12 @@ const OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE = new SafeInjectionToken< >("OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE"); const needsBackgroundInit = BrowserPopupUtils.backgroundInitializationRequired(); -const isPrivateMode = BrowserPopupUtils.inPrivateMode(); const mainBackground: MainBackground = needsBackgroundInit ? createLocalBgService() : BrowserApi.getBackgroundPage().bitwardenMain; function createLocalBgService() { - const localBgService = new MainBackground(isPrivateMode, true); + const localBgService = new MainBackground(true); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises localBgService.bootstrap(); @@ -220,12 +224,48 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: CryptoService, - useFactory: (encryptService: EncryptService) => { - const cryptoService = getBgService("cryptoService")(); + useFactory: ( + masterPasswordService: InternalMasterPasswordServiceAbstraction, + keyGenerationService: KeyGenerationService, + cryptoFunctionService: CryptoFunctionService, + encryptService: EncryptService, + platformUtilsService: PlatformUtilsService, + logService: LogService, + stateService: StateServiceAbstraction, + accountService: AccountServiceAbstraction, + stateProvider: StateProvider, + biometricStateService: BiometricStateService, + kdfConfigService: KdfConfigService, + ) => { + const cryptoService = new BrowserCryptoService( + masterPasswordService, + keyGenerationService, + cryptoFunctionService, + encryptService, + platformUtilsService, + logService, + stateService, + accountService, + stateProvider, + biometricStateService, + kdfConfigService, + ); new ContainerService(cryptoService, encryptService).attachToGlobal(self); return cryptoService; }, - deps: [EncryptService], + deps: [ + InternalMasterPasswordServiceAbstraction, + KeyGenerationService, + CryptoFunctionService, + EncryptService, + PlatformUtilsService, + LogService, + StateServiceAbstraction, + AccountServiceAbstraction, + StateProvider, + BiometricStateService, + KdfConfigService, + ], }), safeProvider({ provide: TotpServiceAbstraction, diff --git a/apps/cli/src/platform/services/console-log.service.spec.ts b/apps/cli/src/platform/services/console-log.service.spec.ts index 10a0ad8cca..03598b16e6 100644 --- a/apps/cli/src/platform/services/console-log.service.spec.ts +++ b/apps/cli/src/platform/services/console-log.service.spec.ts @@ -2,13 +2,18 @@ import { interceptConsole, restoreConsole } from "@bitwarden/common/spec"; import { ConsoleLogService } from "./console-log.service"; -let caughtMessage: any = {}; - describe("CLI Console log service", () => { + const error = new Error("this is an error"); + const obj = { a: 1, b: 2 }; let logService: ConsoleLogService; + let consoleSpy: { + log: jest.Mock; + warn: jest.Mock; + error: jest.Mock; + }; + beforeEach(() => { - caughtMessage = {}; - interceptConsole(caughtMessage); + consoleSpy = interceptConsole(); logService = new ConsoleLogService(true); }); @@ -19,24 +24,21 @@ describe("CLI Console log service", () => { it("should redirect all console to error if BW_RESPONSE env is true", () => { process.env.BW_RESPONSE = "true"; - logService.debug("this is a debug message"); - expect(caughtMessage).toMatchObject({ - error: { 0: "this is a debug message" }, - }); + logService.debug("this is a debug message", error, obj); + expect(consoleSpy.error).toHaveBeenCalledWith("this is a debug message", error, obj); }); it("should not redirect console to error if BW_RESPONSE != true", () => { process.env.BW_RESPONSE = "false"; - logService.debug("debug"); - logService.info("info"); - logService.warning("warning"); - logService.error("error"); + logService.debug("debug", error, obj); + logService.info("info", error, obj); + logService.warning("warning", error, obj); + logService.error("error", error, obj); - expect(caughtMessage).toMatchObject({ - log: { 0: "info" }, - warn: { 0: "warning" }, - error: { 0: "error" }, - }); + expect(consoleSpy.log).toHaveBeenCalledWith("debug", error, obj); + expect(consoleSpy.log).toHaveBeenCalledWith("info", error, obj); + expect(consoleSpy.warn).toHaveBeenCalledWith("warning", error, obj); + expect(consoleSpy.error).toHaveBeenCalledWith("error", error, obj); }); }); diff --git a/apps/cli/src/platform/services/console-log.service.ts b/apps/cli/src/platform/services/console-log.service.ts index a35dae71fc..5bdc0b4015 100644 --- a/apps/cli/src/platform/services/console-log.service.ts +++ b/apps/cli/src/platform/services/console-log.service.ts @@ -6,17 +6,17 @@ export class ConsoleLogService extends BaseConsoleLogService { super(isDev, filter); } - write(level: LogLevelType, message: string) { + write(level: LogLevelType, message?: any, ...optionalParams: any[]) { if (this.filter != null && this.filter(level)) { return; } if (process.env.BW_RESPONSE === "true") { // eslint-disable-next-line - console.error(message); + console.error(message, ...optionalParams); return; } - super.write(level, message); + super.write(level, message, ...optionalParams); } } diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 771d25ef0a..d81d647652 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -103,7 +103,8 @@ export default { isMacAppStore: isMacAppStore(), isWindowsStore: isWindowsStore(), reloadProcess: () => ipcRenderer.send("reload-process"), - log: (level: LogLevelType, message: string) => ipcRenderer.invoke("ipc.log", { level, message }), + log: (level: LogLevelType, message?: any, ...optionalParams: any[]) => + ipcRenderer.invoke("ipc.log", { level, message, optionalParams }), openContextMenu: ( menu: { diff --git a/apps/desktop/src/platform/services/electron-log.main.service.ts b/apps/desktop/src/platform/services/electron-log.main.service.ts index 832365785c..0725de3dc9 100644 --- a/apps/desktop/src/platform/services/electron-log.main.service.ts +++ b/apps/desktop/src/platform/services/electron-log.main.service.ts @@ -25,28 +25,28 @@ export class ElectronLogMainService extends BaseLogService { } log.initialize(); - ipcMain.handle("ipc.log", (_event, { level, message }) => { - this.write(level, message); + ipcMain.handle("ipc.log", (_event, { level, message, optionalParams }) => { + this.write(level, message, ...optionalParams); }); } - write(level: LogLevelType, message: string) { + write(level: LogLevelType, message?: any, ...optionalParams: any[]) { if (this.filter != null && this.filter(level)) { return; } switch (level) { case LogLevelType.Debug: - log.debug(message); + log.debug(message, ...optionalParams); break; case LogLevelType.Info: - log.info(message); + log.info(message, ...optionalParams); break; case LogLevelType.Warning: - log.warn(message); + log.warn(message, ...optionalParams); break; case LogLevelType.Error: - log.error(message); + log.error(message, ...optionalParams); break; default: break; diff --git a/apps/desktop/src/platform/services/electron-log.renderer.service.ts b/apps/desktop/src/platform/services/electron-log.renderer.service.ts index e0e0757e6a..cea939f160 100644 --- a/apps/desktop/src/platform/services/electron-log.renderer.service.ts +++ b/apps/desktop/src/platform/services/electron-log.renderer.service.ts @@ -6,27 +6,29 @@ export class ElectronLogRendererService extends BaseLogService { super(ipc.platform.isDev, filter); } - write(level: LogLevelType, message: string) { + write(level: LogLevelType, message?: any, ...optionalParams: any[]) { if (this.filter != null && this.filter(level)) { return; } /* eslint-disable no-console */ - ipc.platform.log(level, message).catch((e) => console.log("Error logging", e)); + ipc.platform + .log(level, message, ...optionalParams) + .catch((e) => console.log("Error logging", e)); /* eslint-disable no-console */ switch (level) { case LogLevelType.Debug: - console.debug(message); + console.debug(message, ...optionalParams); break; case LogLevelType.Info: - console.info(message); + console.info(message, ...optionalParams); break; case LogLevelType.Warning: - console.warn(message); + console.warn(message, ...optionalParams); break; case LogLevelType.Error: - console.error(message); + console.error(message, ...optionalParams); break; default: break; diff --git a/apps/web/src/app/admin-console/organizations/tools/exposed-passwords-report.component.ts b/apps/web/src/app/admin-console/organizations/tools/exposed-passwords-report.component.ts index 54a4bb18b7..d354459ee9 100644 --- a/apps/web/src/app/admin-console/organizations/tools/exposed-passwords-report.component.ts +++ b/apps/web/src/app/admin-console/organizations/tools/exposed-passwords-report.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute } from "@angular/router"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -27,11 +28,20 @@ export class ExposedPasswordsReportComponent extends BaseExposedPasswordsReportC organizationService: OrganizationService, private route: ActivatedRoute, passwordRepromptService: PasswordRepromptService, + i18nService: I18nService, ) { - super(cipherService, auditService, organizationService, modalService, passwordRepromptService); + super( + cipherService, + auditService, + organizationService, + modalService, + passwordRepromptService, + i18nService, + ); } async ngOnInit() { + this.isAdminConsoleActive = true; // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.parent.parent.params.subscribe(async (params) => { this.organization = await this.organizationService.get(params.organizationId); diff --git a/apps/web/src/app/admin-console/organizations/tools/inactive-two-factor-report.component.ts b/apps/web/src/app/admin-console/organizations/tools/inactive-two-factor-report.component.ts index 906451397f..67d4e963b0 100644 --- a/apps/web/src/app/admin-console/organizations/tools/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/admin-console/organizations/tools/inactive-two-factor-report.component.ts @@ -3,6 +3,7 @@ import { ActivatedRoute } from "@angular/router"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -24,11 +25,20 @@ export class InactiveTwoFactorReportComponent extends BaseInactiveTwoFactorRepor logService: LogService, passwordRepromptService: PasswordRepromptService, organizationService: OrganizationService, + i18nService: I18nService, ) { - super(cipherService, organizationService, modalService, logService, passwordRepromptService); + super( + cipherService, + organizationService, + modalService, + logService, + passwordRepromptService, + i18nService, + ); } async ngOnInit() { + this.isAdminConsoleActive = true; // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.parent.parent.params.subscribe(async (params) => { this.organization = await this.organizationService.get(params.organizationId); diff --git a/apps/web/src/app/admin-console/organizations/tools/reused-passwords-report.component.ts b/apps/web/src/app/admin-console/organizations/tools/reused-passwords-report.component.ts index 03418a7279..c8ceb023af 100644 --- a/apps/web/src/app/admin-console/organizations/tools/reused-passwords-report.component.ts +++ b/apps/web/src/app/admin-console/organizations/tools/reused-passwords-report.component.ts @@ -3,6 +3,7 @@ import { ActivatedRoute } from "@angular/router"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -25,11 +26,13 @@ export class ReusedPasswordsReportComponent extends BaseReusedPasswordsReportCom private route: ActivatedRoute, organizationService: OrganizationService, passwordRepromptService: PasswordRepromptService, + i18nService: I18nService, ) { - super(cipherService, organizationService, modalService, passwordRepromptService); + super(cipherService, organizationService, modalService, passwordRepromptService, i18nService); } async ngOnInit() { + this.isAdminConsoleActive = true; // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.parent.parent.params.subscribe(async (params) => { this.organization = await this.organizationService.get(params.organizationId); diff --git a/apps/web/src/app/admin-console/organizations/tools/unsecured-websites-report.component.ts b/apps/web/src/app/admin-console/organizations/tools/unsecured-websites-report.component.ts index 6e1f38e645..2a905b3665 100644 --- a/apps/web/src/app/admin-console/organizations/tools/unsecured-websites-report.component.ts +++ b/apps/web/src/app/admin-console/organizations/tools/unsecured-websites-report.component.ts @@ -3,6 +3,7 @@ import { ActivatedRoute } from "@angular/router"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -22,11 +23,13 @@ export class UnsecuredWebsitesReportComponent extends BaseUnsecuredWebsitesRepor private route: ActivatedRoute, organizationService: OrganizationService, passwordRepromptService: PasswordRepromptService, + i18nService: I18nService, ) { - super(cipherService, organizationService, modalService, passwordRepromptService); + super(cipherService, organizationService, modalService, passwordRepromptService, i18nService); } async ngOnInit() { + this.isAdminConsoleActive = true; // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.parent.parent.params.subscribe(async (params) => { this.organization = await this.organizationService.get(params.organizationId); diff --git a/apps/web/src/app/admin-console/organizations/tools/weak-passwords-report.component.ts b/apps/web/src/app/admin-console/organizations/tools/weak-passwords-report.component.ts index a79691c01b..8820e596e3 100644 --- a/apps/web/src/app/admin-console/organizations/tools/weak-passwords-report.component.ts +++ b/apps/web/src/app/admin-console/organizations/tools/weak-passwords-report.component.ts @@ -3,6 +3,7 @@ import { ActivatedRoute } from "@angular/router"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -27,6 +28,7 @@ export class WeakPasswordsReportComponent extends BaseWeakPasswordsReportCompone private route: ActivatedRoute, organizationService: OrganizationService, passwordRepromptService: PasswordRepromptService, + i18nService: I18nService, ) { super( cipherService, @@ -34,10 +36,12 @@ export class WeakPasswordsReportComponent extends BaseWeakPasswordsReportCompone organizationService, modalService, passwordRepromptService, + i18nService, ); } async ngOnInit() { + this.isAdminConsoleActive = true; // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.parent.parent.params.subscribe(async (params) => { this.organization = await this.organizationService.get(params.organizationId); diff --git a/apps/web/src/app/billing/organizations/icons/manage-billing.icon.ts b/apps/web/src/app/billing/organizations/icons/manage-billing.icon.ts new file mode 100644 index 0000000000..6f583bf2e8 --- /dev/null +++ b/apps/web/src/app/billing/organizations/icons/manage-billing.icon.ts @@ -0,0 +1,25 @@ +import { svgIcon } from "@bitwarden/components"; + +export const ManageBilling = svgIcon` + + + + + + + + + + + + + + + + + + + + + + `; diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 16641c0d52..38903bab19 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -1,6 +1,6 @@ - + {{ "loading" | i18n }} @@ -256,3 +256,13 @@ + +
+ + {{ + "manageBillingFromProviderPortalMessage" | i18n + }} +
+
diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 477032deba..7acb108808 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -5,7 +5,7 @@ import { concatMap, firstValueFrom, lastValueFrom, Observable, Subject, takeUnti import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { OrganizationApiKeyType } from "@bitwarden/common/admin-console/enums"; +import { OrganizationApiKeyType, ProviderType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { PlanType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; @@ -28,6 +28,7 @@ import { } from "../shared/offboarding-survey.component"; import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; +import { ManageBilling } from "./icons/manage-billing.icon"; import { SecretsManagerSubscriptionOptions } from "./sm-adjust-subscription.component"; @Component({ @@ -47,11 +48,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy loading: boolean; locale: string; showUpdatedSubscriptionStatusSection$: Observable; + manageBillingFromProviderPortal = ManageBilling; + IsProviderManaged = false; protected readonly teamsStarter = ProductType.TeamsStarter; private destroy$ = new Subject(); + protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$( + FeatureFlag.EnableConsolidatedBilling, + ); + constructor( private apiService: ApiService, private platformUtilsService: PlatformUtilsService, @@ -99,6 +106,13 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy this.loading = true; this.locale = await firstValueFrom(this.i18nService.locale$); this.userOrg = await this.organizationService.get(this.organizationId); + const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$); + this.IsProviderManaged = + this.userOrg.hasProvider && + this.userOrg.providerType == ProviderType.Msp && + enableConsolidatedBilling + ? true + : false; if (this.userOrg.canViewSubscription) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); this.lineItems = this.sub?.subscription?.items; diff --git a/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html b/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html index 6d6691f336..b4c1224db9 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html @@ -42,7 +42,10 @@ : subscription.expirationWithGracePeriod ) | date: "mediumDate" }} -
+
{{ "selfHostGracePeriodHelp" | i18n: (subscription.expirationWithGracePeriod | date: "mediumDate") diff --git a/apps/web/src/app/tools/reports/pages/cipher-report.component.ts b/apps/web/src/app/tools/reports/pages/cipher-report.component.ts index 0d67b7a769..041307122b 100644 --- a/apps/web/src/app/tools/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/cipher-report.component.ts @@ -1,9 +1,11 @@ -import { Directive, ViewChild, ViewContainerRef } from "@angular/core"; -import { Observable } from "rxjs"; +import { Directive, ViewChild, ViewContainerRef, OnDestroy } from "@angular/core"; +import { BehaviorSubject, Observable, Subject, takeUntil } from "rxjs"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -12,27 +14,111 @@ import { AddEditComponent } from "../../../vault/individual-vault/add-edit.compo import { AddEditComponent as OrgAddEditComponent } from "../../../vault/org-vault/add-edit.component"; @Directive() -export class CipherReportComponent { +export class CipherReportComponent implements OnDestroy { @ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true }) cipherAddEditModalRef: ViewContainerRef; + isAdminConsoleActive = false; loading = false; hasLoaded = false; ciphers: CipherView[] = []; + allCiphers: CipherView[] = []; organization: Organization; + organizations: Organization[]; organizations$: Observable; + filterStatus: any = [0]; + showFilterToggle: boolean = false; + vaultMsg: string = "vault"; + currentFilterStatus: number | string; + protected filterOrgStatus$ = new BehaviorSubject(0); + private destroyed$: Subject = new Subject(); + constructor( + protected cipherService: CipherService, private modalService: ModalService, protected passwordRepromptService: PasswordRepromptService, protected organizationService: OrganizationService, + protected i18nService: I18nService, ) { this.organizations$ = this.organizationService.organizations$; + this.organizations$.pipe(takeUntil(this.destroyed$)).subscribe((orgs) => { + this.organizations = orgs; + }); + } + + ngOnDestroy(): void { + this.destroyed$.next(); + this.destroyed$.complete(); + } + + getName(filterId: string | number) { + let orgName: any; + + if (filterId === 0) { + orgName = this.i18nService.t("all"); + } else if (filterId === 1) { + orgName = this.i18nService.t("me"); + } else { + this.organizations.filter((org: Organization) => { + if (org.id === filterId) { + orgName = org.name; + return org; + } + }); + } + return orgName; + } + + getCount(filterId: string | number) { + let orgFilterStatus: any; + let cipherCount; + + if (filterId === 0) { + cipherCount = this.allCiphers.length; + } else if (filterId === 1) { + cipherCount = this.allCiphers.filter((c: any) => c.orgFilterStatus === null).length; + } else { + this.organizations.filter((org: Organization) => { + if (org.id === filterId) { + orgFilterStatus = org.id; + return org; + } + }); + cipherCount = this.allCiphers.filter( + (c: any) => c.orgFilterStatus === orgFilterStatus, + ).length; + } + return cipherCount; + } + + async filterOrgToggle(status: any) { + this.currentFilterStatus = status; + await this.setCiphers(); + if (status === 0) { + return; + } else if (status === 1) { + this.ciphers = this.ciphers.filter((c: any) => c.orgFilterStatus == null); + } else { + this.ciphers = this.ciphers.filter((c: any) => c.orgFilterStatus === status); + } } async load() { this.loading = true; - await this.setCiphers(); + // when a user fixes an item in a report we want to persist the filter they had + // if they fix the last item of that filter we will go back to the "All" filter + if (this.currentFilterStatus) { + if (this.ciphers.length > 2) { + this.filterOrgStatus$.next(this.currentFilterStatus); + await this.filterOrgToggle(this.currentFilterStatus); + } else { + this.filterOrgStatus$.next(0); + await this.filterOrgToggle(0); + } + } else { + await this.setCiphers(); + } this.loading = false; this.hasLoaded = true; } @@ -76,7 +162,7 @@ export class CipherReportComponent { } protected async setCiphers() { - this.ciphers = []; + this.allCiphers = []; } protected async repromptCipher(c: CipherView) { @@ -85,4 +171,32 @@ export class CipherReportComponent { (await this.passwordRepromptService.showPasswordPrompt()) ); } + + protected async getAllCiphers(): Promise { + return await this.cipherService.getAllDecrypted(); + } + + protected filterCiphersByOrg(ciphersList: CipherView[]) { + this.allCiphers = [...ciphersList]; + + this.ciphers = ciphersList.map((ciph: any) => { + ciph.orgFilterStatus = ciph.organizationId; + + if (this.filterStatus.indexOf(ciph.organizationId) === -1 && ciph.organizationId != null) { + this.filterStatus.push(ciph.organizationId); + } else if (this.filterStatus.indexOf(1) === -1 && ciph.organizationId == null) { + this.filterStatus.splice(1, 0, 1); + } + return ciph; + }); + + if (this.filterStatus.length > 2) { + this.showFilterToggle = true; + this.vaultMsg = "vaults"; + } else { + // If a user fixes an item and there is only one item left remove the filter toggle and change the vault message to singular + this.showFilterToggle = false; + this.vaultMsg = "vault"; + } + } } diff --git a/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.html b/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.html index af80e2e62b..30801a42fd 100644 --- a/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.html +++ b/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.html @@ -11,9 +11,32 @@ - {{ "exposedPasswordsFoundDesc" | i18n: (ciphers.length | number) }} + {{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + + + + + + +
{{ "name" | i18n }}{{ "owner" | i18n }}
diff --git a/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.spec.ts b/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.spec.ts index d1cf89eb08..7b73ad8305 100644 --- a/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.spec.ts +++ b/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.spec.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line no-restricted-imports import { ComponentFixture, TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -17,9 +18,12 @@ describe("ExposedPasswordsReportComponent", () => { let component: ExposedPasswordsReportComponent; let fixture: ComponentFixture; let auditService: MockProxy; + let organizationService: MockProxy; beforeEach(() => { auditService = mock(); + organizationService = mock(); + organizationService.organizations$ = of([]); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises TestBed.configureTestingModule({ @@ -35,7 +39,7 @@ describe("ExposedPasswordsReportComponent", () => { }, { provide: OrganizationService, - useValue: mock(), + useValue: organizationService, }, { provide: ModalService, diff --git a/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.ts index 631e9ef8a8..39414487d7 100644 --- a/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/exposed-passwords-report.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit } from "@angular/core"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -24,8 +25,9 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple protected organizationService: OrganizationService, modalService: ModalService, passwordRepromptService: PasswordRepromptService, + i18nService: I18nService, ) { - super(modalService, passwordRepromptService, organizationService); + super(cipherService, modalService, passwordRepromptService, organizationService, i18nService); } async ngOnInit() { @@ -36,7 +38,9 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple const allCiphers = await this.getAllCiphers(); const exposedPasswordCiphers: CipherView[] = []; const promises: Promise[] = []; - allCiphers.forEach((ciph) => { + this.filterStatus = [0]; + + allCiphers.forEach((ciph: any) => { const { type, login, isDeleted, edit, viewPassword, id } = ciph; if ( type !== CipherType.Login || @@ -48,6 +52,7 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple ) { return; } + const promise = this.auditService.passwordLeaked(login.password).then((exposedCount) => { if (exposedCount > 0) { exposedPasswordCiphers.push(ciph); @@ -57,11 +62,8 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple promises.push(promise); }); await Promise.all(promises); - this.ciphers = [...exposedPasswordCiphers]; - } - protected getAllCiphers(): Promise { - return this.cipherService.getAllDecrypted(); + this.filterCiphersByOrg(exposedPasswordCiphers); } protected canManageCipher(c: CipherView): boolean { diff --git a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html index d81fc2d413..ae03a3bcb8 100644 --- a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html +++ b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html @@ -16,9 +16,32 @@ - {{ "inactive2faFoundDesc" | i18n: (ciphers.length | number) }} + {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + + + + + + +
{{ "name" | i18n }}{{ "owner" | i18n }}
diff --git a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.spec.ts b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.spec.ts index 97321480fa..528f6306e0 100644 --- a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.spec.ts +++ b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.spec.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line no-restricted-imports import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { mock } from "jest-mock-extended"; +import { MockProxy, mock } from "jest-mock-extended"; +import { of } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -16,8 +17,11 @@ import { cipherData } from "./reports-ciphers.mock"; describe("InactiveTwoFactorReportComponent", () => { let component: InactiveTwoFactorReportComponent; let fixture: ComponentFixture; + let organizationService: MockProxy; beforeEach(() => { + organizationService = mock(); + organizationService.organizations$ = of([]); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises TestBed.configureTestingModule({ @@ -29,7 +33,7 @@ describe("InactiveTwoFactorReportComponent", () => { }, { provide: OrganizationService, - useValue: mock(), + useValue: organizationService, }, { provide: ModalService, diff --git a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.ts b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.ts index 15b79981b6..956607c8fb 100644 --- a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -26,8 +27,9 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl modalService: ModalService, private logService: LogService, passwordRepromptService: PasswordRepromptService, + i18nService: I18nService, ) { - super(modalService, passwordRepromptService, organizationService); + super(cipherService, modalService, passwordRepromptService, organizationService, i18nService); } async ngOnInit() { @@ -45,6 +47,7 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl const allCiphers = await this.getAllCiphers(); const inactive2faCiphers: CipherView[] = []; const docs = new Map(); + this.filterStatus = [0]; allCiphers.forEach((ciph) => { const { type, login, isDeleted, edit, id, viewPassword } = ciph; @@ -58,6 +61,7 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl ) { return; } + for (let i = 0; i < login.uris.length; i++) { const u = login.uris[i]; if (u.uri != null && u.uri !== "") { @@ -75,15 +79,12 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl } } }); - this.ciphers = [...inactive2faCiphers]; + + this.filterCiphersByOrg(inactive2faCiphers); this.cipherDocs = docs; } } - protected getAllCiphers(): Promise { - return this.cipherService.getAllDecrypted(); - } - private async load2fa() { if (this.services.size > 0) { return; diff --git a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html index cde2e59ea8..549773ba8c 100644 --- a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html +++ b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html @@ -16,9 +16,34 @@ - {{ "reusedPasswordsFoundDesc" | i18n: (ciphers.length | number) }} + {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + + + + + + + +
{{ "name" | i18n }}{{ "owner" | i18n }}
diff --git a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.spec.ts b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.spec.ts index 450e42805a..29e20c11af 100644 --- a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.spec.ts +++ b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.spec.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line no-restricted-imports import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { mock } from "jest-mock-extended"; +import { MockProxy, mock } from "jest-mock-extended"; +import { of } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -15,8 +16,11 @@ import { ReusedPasswordsReportComponent } from "./reused-passwords-report.compon describe("ReusedPasswordsReportComponent", () => { let component: ReusedPasswordsReportComponent; let fixture: ComponentFixture; + let organizationService: MockProxy; beforeEach(() => { + organizationService = mock(); + organizationService.organizations$ = of([]); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises TestBed.configureTestingModule({ @@ -28,7 +32,7 @@ describe("ReusedPasswordsReportComponent", () => { }, { provide: OrganizationService, - useValue: mock(), + useValue: organizationService, }, { provide: ModalService, diff --git a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.ts index f785186c15..cbc2ea11b5 100644 --- a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -22,8 +23,9 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem protected organizationService: OrganizationService, modalService: ModalService, passwordRepromptService: PasswordRepromptService, + i18nService: I18nService, ) { - super(modalService, passwordRepromptService, organizationService); + super(cipherService, modalService, passwordRepromptService, organizationService, i18nService); } async ngOnInit() { @@ -34,6 +36,8 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem const allCiphers = await this.getAllCiphers(); const ciphersWithPasswords: CipherView[] = []; this.passwordUseMap = new Map(); + this.filterStatus = [0]; + allCiphers.forEach((ciph) => { const { type, login, isDeleted, edit, viewPassword } = ciph; if ( @@ -46,6 +50,7 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem ) { return; } + ciphersWithPasswords.push(ciph); if (this.passwordUseMap.has(login.password)) { this.passwordUseMap.set(login.password, this.passwordUseMap.get(login.password) + 1); @@ -57,11 +62,8 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem (c) => this.passwordUseMap.has(c.login.password) && this.passwordUseMap.get(c.login.password) > 1, ); - this.ciphers = reusedPasswordCiphers; - } - protected getAllCiphers(): Promise { - return this.cipherService.getAllDecrypted(); + this.filterCiphersByOrg(reusedPasswordCiphers); } protected canManageCipher(c: CipherView): boolean { diff --git a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html index 616bdbba0b..ced0ff9731 100644 --- a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html +++ b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html @@ -16,9 +16,33 @@ - {{ "unsecuredWebsitesFoundDesc" | i18n: (ciphers.length | number) }} + {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + + + + + + +
{{ "name" | i18n }}{{ "owner" | i18n }}
diff --git a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.spec.ts b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.spec.ts index 5cdf640c55..3b7c6d350f 100644 --- a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.spec.ts +++ b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.spec.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line no-restricted-imports import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { mock } from "jest-mock-extended"; +import { MockProxy, mock } from "jest-mock-extended"; +import { of } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -15,8 +16,11 @@ import { UnsecuredWebsitesReportComponent } from "./unsecured-websites-report.co describe("UnsecuredWebsitesReportComponent", () => { let component: UnsecuredWebsitesReportComponent; let fixture: ComponentFixture; + let organizationService: MockProxy; beforeEach(() => { + organizationService = mock(); + organizationService.organizations$ = of([]); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises TestBed.configureTestingModule({ @@ -28,7 +32,7 @@ describe("UnsecuredWebsitesReportComponent", () => { }, { provide: OrganizationService, - useValue: mock(), + useValue: organizationService, }, { provide: ModalService, diff --git a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.ts b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.ts index 2de70e928b..769eb058cd 100644 --- a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.ts @@ -2,9 +2,9 @@ import { Component, OnInit } from "@angular/core"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordRepromptService } from "@bitwarden/vault"; import { CipherReportComponent } from "./cipher-report.component"; @@ -21,8 +21,9 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl protected organizationService: OrganizationService, modalService: ModalService, passwordRepromptService: PasswordRepromptService, + i18nService: I18nService, ) { - super(modalService, passwordRepromptService, organizationService); + super(cipherService, modalService, passwordRepromptService, organizationService, i18nService); } async ngOnInit() { @@ -31,18 +32,15 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl async setCiphers() { const allCiphers = await this.getAllCiphers(); + this.filterStatus = [0]; const unsecuredCiphers = allCiphers.filter((c) => { if (c.type !== CipherType.Login || !c.login.hasUris || c.isDeleted) { return false; } - return c.login.uris.some((u) => u.uri != null && u.uri.indexOf("http://") === 0); - }); - this.ciphers = unsecuredCiphers.filter( - (c) => (!this.organization && c.edit) || (this.organization && !c.edit), - ); - } - protected getAllCiphers(): Promise { - return this.cipherService.getAllDecrypted(); + return c.login.uris.some((u: any) => u.uri != null && u.uri.indexOf("http://") === 0); + }); + + this.filterCiphersByOrg(unsecuredCiphers); } } diff --git a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html index b4c77b2fa1..a943c8c29e 100644 --- a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html @@ -16,9 +16,32 @@ - {{ "weakPasswordsFoundDesc" | i18n: (ciphers.length | number) }} + {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + + + + + + +
{{ "name" | i18n }}{{ "owner" | i18n }}
diff --git a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.spec.ts b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.spec.ts index f1446c4209..dbc367b108 100644 --- a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.spec.ts +++ b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.spec.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line no-restricted-imports import { ComponentFixture, TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -17,9 +18,12 @@ describe("WeakPasswordsReportComponent", () => { let component: WeakPasswordsReportComponent; let fixture: ComponentFixture; let passwordStrengthService: MockProxy; + let organizationService: MockProxy; beforeEach(() => { passwordStrengthService = mock(); + organizationService = mock(); + organizationService.organizations$ = of([]); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises TestBed.configureTestingModule({ @@ -35,7 +39,7 @@ describe("WeakPasswordsReportComponent", () => { }, { provide: OrganizationService, - useValue: mock(), + useValue: organizationService, }, { provide: ModalService, diff --git a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts index a7ed119e19..4d179b58f3 100644 --- a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -29,8 +30,9 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen protected organizationService: OrganizationService, modalService: ModalService, passwordRepromptService: PasswordRepromptService, + i18nService: I18nService, ) { - super(modalService, passwordRepromptService, organizationService); + super(cipherService, modalService, passwordRepromptService, organizationService, i18nService); } async ngOnInit() { @@ -38,7 +40,10 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen } async setCiphers() { - const allCiphers = await this.getAllCiphers(); + const allCiphers: any = await this.getAllCiphers(); + this.passwordStrengthCache = new Map(); + this.weakPasswordCiphers = []; + this.filterStatus = [0]; this.findWeakPasswords(allCiphers); } @@ -55,6 +60,7 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen ) { return; } + const hasUserName = this.isUserNameNotEmpty(ciph); const cacheKey = this.getCacheKey(ciph); if (!this.passwordStrengthCache.has(cacheKey)) { @@ -87,6 +93,7 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen this.passwordStrengthCache.set(cacheKey, result.score); } const score = this.passwordStrengthCache.get(cacheKey); + if (score != null && score <= 2) { this.passwordStrengthMap.set(id, this.scoreKey(score)); this.weakPasswordCiphers.push(ciph); @@ -98,11 +105,8 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen this.passwordStrengthCache.get(this.getCacheKey(b)) ); }); - this.ciphers = [...this.weakPasswordCiphers]; - } - protected getAllCiphers(): Promise { - return this.cipherService.getAllDecrypted(); + this.filterCiphersByOrg(this.weakPasswordCiphers); } protected canManageCipher(c: CipherView): boolean { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 2ba3c2e903..de948caa2f 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1809,12 +1809,16 @@ "unsecuredWebsitesFound": { "message": "Unsecured websites found" }, - "unsecuredWebsitesFoundDesc": { - "message": "We found $COUNT$ items in your vault with unsecured URIs. You should change their URI scheme to https:// if the website allows it.", + "unsecuredWebsitesFoundReportDesc": { + "message": "We found $COUNT$ items in your $VAULT$ with unsecured URIs. You should change their URI scheme to https:// if the website allows it.", "placeholders": { "count": { "content": "$1", "example": "8" + }, + "vault": { + "content": "$2", + "example": "this will be 'vault' or 'vaults'" } } }, @@ -1830,12 +1834,16 @@ "inactive2faFound": { "message": "Logins without two-step login found" }, - "inactive2faFoundDesc": { - "message": "We found $COUNT$ website(s) in your vault that may not be configured with two-step login (according to 2fa.directory). To further protect these accounts, you should set up two-step login.", + "inactive2faFoundReportDesc": { + "message": "We found $COUNT$ website(s) in your $VAULT$ that may not be configured with two-step login (according to 2fa.directory). To further protect these accounts, you should set up two-step login.", "placeholders": { "count": { "content": "$1", "example": "8" + }, + "vault": { + "content": "$2", + "example": "this will be 'vault' or 'vaults'" } } }, @@ -1854,12 +1862,16 @@ "exposedPasswordsFound": { "message": "Exposed passwords found" }, - "exposedPasswordsFoundDesc": { - "message": "We found $COUNT$ items in your vault that have passwords that were exposed in known data breaches. You should change them to use a new password.", + "exposedPasswordsFoundReportDesc": { + "message": "We found $COUNT$ items in your $VAULT$ that have passwords that were exposed in known data breaches. You should change them to use a new password.", "placeholders": { "count": { "content": "$1", "example": "8" + }, + "vault": { + "content": "$2", + "example": "this will be 'vault' or 'vaults'" } } }, @@ -1887,12 +1899,16 @@ "weakPasswordsFound": { "message": "Weak passwords found" }, - "weakPasswordsFoundDesc": { - "message": "We found $COUNT$ items in your vault with passwords that are not strong. You should update them to use stronger passwords.", + "weakPasswordsFoundReportDesc": { + "message": "We found $COUNT$ items in your $VAULT$ with passwords that are not strong. You should update them to use stronger passwords.", "placeholders": { "count": { "content": "$1", "example": "8" + }, + "vault": { + "content": "$2", + "example": "this will be 'vault' or 'vaults'" } } }, @@ -1908,12 +1924,16 @@ "reusedPasswordsFound": { "message": "Reused passwords found" }, - "reusedPasswordsFoundDesc": { - "message": "We found $COUNT$ passwords that are being reused in your vault. You should change them to a unique value.", + "reusedPasswordsFoundReportDesc": { + "message": "We found $COUNT$ passwords that are being reused in your $VAULT$. You should change them to a unique value.", "placeholders": { "count": { "content": "$1", "example": "8" + }, + "vault": { + "content": "$2", + "example": "this will be 'vault' or 'vaults'" } } }, @@ -8055,5 +8075,8 @@ }, "collectionItemSelect": { "message": "Select collection item" + }, + "manageBillingFromProviderPortalMessage": { + "message": "Manage billing from the Provider Portal" } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts index 554e7fa37d..0547c4fcba 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts @@ -17,7 +17,7 @@ import { ServiceAccountEventLogApiService } from "./service-account-event-log-ap templateUrl: "./service-accounts-events.component.html", }) export class ServiceAccountEventsComponent extends BaseEventsComponent implements OnDestroy { - exportFileName = "service-account-events"; + exportFileName = "machine-account-events"; private destroy$ = new Subject(); private serviceAccountId: string; diff --git a/libs/angular/src/platform/services/logging-error-handler.ts b/libs/angular/src/platform/services/logging-error-handler.ts index 522412dd28..5644272d35 100644 --- a/libs/angular/src/platform/services/logging-error-handler.ts +++ b/libs/angular/src/platform/services/logging-error-handler.ts @@ -14,7 +14,7 @@ export class LoggingErrorHandler extends ErrorHandler { override handleError(error: any): void { try { const logService = this.injector.get(LogService, null); - logService.error(error); + logService.error("Unhandled error in angular", error); } catch { super.handleError(error); } diff --git a/libs/common/spec/intercept-console.ts b/libs/common/spec/intercept-console.ts index 01c4063e7a..565d475cae 100644 --- a/libs/common/spec/intercept-console.ts +++ b/libs/common/spec/intercept-console.ts @@ -2,22 +2,17 @@ const originalConsole = console; declare let console: any; -export function interceptConsole(interceptions: any): object { +export function interceptConsole(): { + log: jest.Mock; + warn: jest.Mock; + error: jest.Mock; +} { console = { - log: function () { - // eslint-disable-next-line - interceptions.log = arguments; - }, - warn: function () { - // eslint-disable-next-line - interceptions.warn = arguments; - }, - error: function () { - // eslint-disable-next-line - interceptions.error = arguments; - }, + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), }; - return interceptions; + return console; } export function restoreConsole() { diff --git a/libs/common/src/billing/models/view/self-hosted-organization-subscription.view.ts b/libs/common/src/billing/models/view/self-hosted-organization-subscription.view.ts index c1f5640207..7b49688294 100644 --- a/libs/common/src/billing/models/view/self-hosted-organization-subscription.view.ts +++ b/libs/common/src/billing/models/view/self-hosted-organization-subscription.view.ts @@ -58,4 +58,16 @@ export class SelfHostedOrganizationSubscriptionView implements View { get isExpiredAndOutsideGracePeriod() { return this.hasExpiration && this.expirationWithGracePeriod < new Date(); } + + /** + * In the case of a trial, where there is no grace period, the expirationWithGracePeriod and expirationWithoutGracePeriod will + * be exactly the same. This can be used to hide the grace period note. + */ + get isInTrial() { + return ( + this.expirationWithGracePeriod && + this.expirationWithoutGracePeriod && + this.expirationWithGracePeriod.getTime() === this.expirationWithoutGracePeriod.getTime() + ); + } } diff --git a/libs/common/src/platform/abstractions/log.service.ts b/libs/common/src/platform/abstractions/log.service.ts index dffa3ca8d3..d77a4f6990 100644 --- a/libs/common/src/platform/abstractions/log.service.ts +++ b/libs/common/src/platform/abstractions/log.service.ts @@ -1,9 +1,9 @@ import { LogLevelType } from "../enums/log-level-type.enum"; export abstract class LogService { - abstract debug(message: string): void; - abstract info(message: string): void; - abstract warning(message: string): void; - abstract error(message: string): void; - abstract write(level: LogLevelType, message: string): void; + abstract debug(message?: any, ...optionalParams: any[]): void; + abstract info(message?: any, ...optionalParams: any[]): void; + abstract warning(message?: any, ...optionalParams: any[]): void; + abstract error(message?: any, ...optionalParams: any[]): void; + abstract write(level: LogLevelType, message?: any, ...optionalParams: any[]): void; } diff --git a/libs/common/src/platform/services/console-log.service.spec.ts b/libs/common/src/platform/services/console-log.service.spec.ts index 129969bbc4..508ca4eb32 100644 --- a/libs/common/src/platform/services/console-log.service.spec.ts +++ b/libs/common/src/platform/services/console-log.service.spec.ts @@ -2,13 +2,18 @@ import { interceptConsole, restoreConsole } from "../../../spec"; import { ConsoleLogService } from "./console-log.service"; -let caughtMessage: any; - describe("ConsoleLogService", () => { + const error = new Error("this is an error"); + const obj = { a: 1, b: 2 }; + let consoleSpy: { + log: jest.Mock; + warn: jest.Mock; + error: jest.Mock; + }; let logService: ConsoleLogService; + beforeEach(() => { - caughtMessage = {}; - interceptConsole(caughtMessage); + consoleSpy = interceptConsole(); logService = new ConsoleLogService(true); }); @@ -18,41 +23,41 @@ describe("ConsoleLogService", () => { it("filters messages below the set threshold", () => { logService = new ConsoleLogService(true, () => true); - logService.debug("debug"); - logService.info("info"); - logService.warning("warning"); - logService.error("error"); + logService.debug("debug", error, obj); + logService.info("info", error, obj); + logService.warning("warning", error, obj); + logService.error("error", error, obj); - expect(caughtMessage).toEqual({}); + expect(consoleSpy.log).not.toHaveBeenCalled(); + expect(consoleSpy.warn).not.toHaveBeenCalled(); + expect(consoleSpy.error).not.toHaveBeenCalled(); }); + it("only writes debug messages in dev mode", () => { logService = new ConsoleLogService(false); logService.debug("debug message"); - expect(caughtMessage.log).toBeUndefined(); + expect(consoleSpy.log).not.toHaveBeenCalled(); }); it("writes debug/info messages to console.log", () => { - logService.debug("this is a debug message"); - expect(caughtMessage).toMatchObject({ - log: { "0": "this is a debug message" }, - }); + logService.debug("this is a debug message", error, obj); + logService.info("this is an info message", error, obj); - logService.info("this is an info message"); - expect(caughtMessage).toMatchObject({ - log: { "0": "this is an info message" }, - }); + expect(consoleSpy.log).toHaveBeenCalledTimes(2); + expect(consoleSpy.log).toHaveBeenCalledWith("this is a debug message", error, obj); + expect(consoleSpy.log).toHaveBeenCalledWith("this is an info message", error, obj); }); + it("writes warning messages to console.warn", () => { - logService.warning("this is a warning message"); - expect(caughtMessage).toMatchObject({ - warn: { 0: "this is a warning message" }, - }); + logService.warning("this is a warning message", error, obj); + + expect(consoleSpy.warn).toHaveBeenCalledWith("this is a warning message", error, obj); }); + it("writes error messages to console.error", () => { - logService.error("this is an error message"); - expect(caughtMessage).toMatchObject({ - error: { 0: "this is an error message" }, - }); + logService.error("this is an error message", error, obj); + + expect(consoleSpy.error).toHaveBeenCalledWith("this is an error message", error, obj); }); }); diff --git a/libs/common/src/platform/services/console-log.service.ts b/libs/common/src/platform/services/console-log.service.ts index 3eb3ad1881..a1480a0c26 100644 --- a/libs/common/src/platform/services/console-log.service.ts +++ b/libs/common/src/platform/services/console-log.service.ts @@ -9,26 +9,26 @@ export class ConsoleLogService implements LogServiceAbstraction { protected filter: (level: LogLevelType) => boolean = null, ) {} - debug(message: string) { + debug(message?: any, ...optionalParams: any[]) { if (!this.isDev) { return; } - this.write(LogLevelType.Debug, message); + this.write(LogLevelType.Debug, message, ...optionalParams); } - info(message: string) { - this.write(LogLevelType.Info, message); + info(message?: any, ...optionalParams: any[]) { + this.write(LogLevelType.Info, message, ...optionalParams); } - warning(message: string) { - this.write(LogLevelType.Warning, message); + warning(message?: any, ...optionalParams: any[]) { + this.write(LogLevelType.Warning, message, ...optionalParams); } - error(message: string) { - this.write(LogLevelType.Error, message); + error(message?: any, ...optionalParams: any[]) { + this.write(LogLevelType.Error, message, ...optionalParams); } - write(level: LogLevelType, message: string) { + write(level: LogLevelType, message?: any, ...optionalParams: any[]) { if (this.filter != null && this.filter(level)) { return; } @@ -36,19 +36,19 @@ export class ConsoleLogService implements LogServiceAbstraction { switch (level) { case LogLevelType.Debug: // eslint-disable-next-line - console.log(message); + console.log(message, ...optionalParams); break; case LogLevelType.Info: // eslint-disable-next-line - console.log(message); + console.log(message, ...optionalParams); break; case LogLevelType.Warning: // eslint-disable-next-line - console.warn(message); + console.warn(message, ...optionalParams); break; case LogLevelType.Error: // eslint-disable-next-line - console.error(message); + console.error(message, ...optionalParams); break; default: break; diff --git a/libs/common/src/state-migrations/migration-helper.spec.ts b/libs/common/src/state-migrations/migration-helper.spec.ts index 162fac2fab..21c5c72a18 100644 --- a/libs/common/src/state-migrations/migration-helper.spec.ts +++ b/libs/common/src/state-migrations/migration-helper.spec.ts @@ -235,6 +235,11 @@ export function mockMigrationHelper( helper.setToUser(userId, keyDefinition, value), ); mockHelper.getAccounts.mockImplementation(() => helper.getAccounts()); + mockHelper.getKnownUserIds.mockImplementation(() => helper.getKnownUserIds()); + mockHelper.removeFromGlobal.mockImplementation((keyDefinition) => + helper.removeFromGlobal(keyDefinition), + ); + mockHelper.remove.mockImplementation((key) => helper.remove(key)); mockHelper.type = helper.type; diff --git a/libs/common/src/state-migrations/migration-helper.ts b/libs/common/src/state-migrations/migration-helper.ts index 5d1de8dd49..b377df8ef9 100644 --- a/libs/common/src/state-migrations/migration-helper.ts +++ b/libs/common/src/state-migrations/migration-helper.ts @@ -175,8 +175,8 @@ export class MigrationHelper { * Helper method to read known users ids. */ async getKnownUserIds(): Promise { - if (this.currentVersion < 61) { - return knownAccountUserIdsBuilderPre61(this.storageService); + if (this.currentVersion < 60) { + return knownAccountUserIdsBuilderPre60(this.storageService); } else { return knownAccountUserIdsBuilder(this.storageService); } @@ -245,7 +245,7 @@ function globalKeyBuilderPre9(): string { throw Error("No key builder should be used for versions prior to 9."); } -async function knownAccountUserIdsBuilderPre61( +async function knownAccountUserIdsBuilderPre60( storageService: AbstractStorageService, ): Promise { return (await storageService.get("authenticatedAccounts")) ?? []; diff --git a/libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts b/libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts index 28dedb3c39..01be4adb6a 100644 --- a/libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts +++ b/libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts @@ -51,17 +51,13 @@ const rollbackJson = () => { }, global_account_accounts: { user1: { - profile: { - email: "user1", - name: "User 1", - emailVerified: true, - }, + email: "user1", + name: "User 1", + emailVerified: true, }, user2: { - profile: { - email: "", - emailVerified: false, - }, + email: "", + emailVerified: false, }, }, global_account_activeAccountId: "user1", diff --git a/libs/common/src/state-migrations/migrations/60-known-accounts.ts b/libs/common/src/state-migrations/migrations/60-known-accounts.ts index 75117da5b4..3b02a5acc4 100644 --- a/libs/common/src/state-migrations/migrations/60-known-accounts.ts +++ b/libs/common/src/state-migrations/migrations/60-known-accounts.ts @@ -38,8 +38,8 @@ export class KnownAccountsMigrator extends Migrator<59, 60> { } async rollback(helper: MigrationHelper): Promise { // authenticated account are removed, but the accounts record also contains logged out accounts. Best we can do is to add them all back - const accounts = (await helper.getFromGlobal>(ACCOUNT_ACCOUNTS)) ?? {}; - await helper.set("authenticatedAccounts", Object.keys(accounts)); + const userIds = (await helper.getKnownUserIds()) ?? []; + await helper.set("authenticatedAccounts", userIds); await helper.removeFromGlobal(ACCOUNT_ACCOUNTS); // Active Account Id diff --git a/libs/components/src/a11y/a11y-cell.directive.ts b/libs/components/src/a11y/a11y-cell.directive.ts new file mode 100644 index 0000000000..fdd75c076f --- /dev/null +++ b/libs/components/src/a11y/a11y-cell.directive.ts @@ -0,0 +1,33 @@ +import { ContentChild, Directive, ElementRef, HostBinding } from "@angular/core"; + +import { FocusableElement } from "../shared/focusable-element"; + +@Directive({ + selector: "bitA11yCell", + standalone: true, + providers: [{ provide: FocusableElement, useExisting: A11yCellDirective }], +}) +export class A11yCellDirective implements FocusableElement { + @HostBinding("attr.role") + role: "gridcell" | null; + + @ContentChild(FocusableElement) + private focusableChild: FocusableElement; + + getFocusTarget() { + let focusTarget: HTMLElement; + if (this.focusableChild) { + focusTarget = this.focusableChild.getFocusTarget(); + } else { + focusTarget = this.elementRef.nativeElement.querySelector("button, a"); + } + + if (!focusTarget) { + return this.elementRef.nativeElement; + } + + return focusTarget; + } + + constructor(private elementRef: ElementRef) {} +} diff --git a/libs/components/src/a11y/a11y-grid.directive.ts b/libs/components/src/a11y/a11y-grid.directive.ts new file mode 100644 index 0000000000..c632376f4f --- /dev/null +++ b/libs/components/src/a11y/a11y-grid.directive.ts @@ -0,0 +1,145 @@ +import { + AfterViewInit, + ContentChildren, + Directive, + HostBinding, + HostListener, + Input, + QueryList, +} from "@angular/core"; + +import type { A11yCellDirective } from "./a11y-cell.directive"; +import { A11yRowDirective } from "./a11y-row.directive"; + +@Directive({ + selector: "bitA11yGrid", + standalone: true, +}) +export class A11yGridDirective implements AfterViewInit { + @HostBinding("attr.role") + role = "grid"; + + @ContentChildren(A11yRowDirective) + rows: QueryList; + + /** The number of pages to navigate on `PageUp` and `PageDown` */ + @Input() pageSize = 5; + + private grid: A11yCellDirective[][]; + + /** The row that currently has focus */ + private activeRow = 0; + + /** The cell that currently has focus */ + private activeCol = 0; + + @HostListener("keydown", ["$event"]) + onKeyDown(event: KeyboardEvent) { + switch (event.code) { + case "ArrowUp": + this.updateCellFocusByDelta(-1, 0); + break; + case "ArrowRight": + this.updateCellFocusByDelta(0, 1); + break; + case "ArrowDown": + this.updateCellFocusByDelta(1, 0); + break; + case "ArrowLeft": + this.updateCellFocusByDelta(0, -1); + break; + case "Home": + this.updateCellFocusByDelta(-this.activeRow, -this.activeCol); + break; + case "End": + this.updateCellFocusByDelta(this.grid.length, this.grid[this.grid.length - 1].length); + break; + case "PageUp": + this.updateCellFocusByDelta(-this.pageSize, 0); + break; + case "PageDown": + this.updateCellFocusByDelta(this.pageSize, 0); + break; + default: + return; + } + + /** Prevent default scrolling behavior */ + event.preventDefault(); + } + + ngAfterViewInit(): void { + this.initializeGrid(); + } + + private initializeGrid(): void { + try { + this.grid = this.rows.map((listItem) => { + listItem.role = "row"; + return [...listItem.cells]; + }); + this.grid.flat().forEach((cell) => { + cell.role = "gridcell"; + cell.getFocusTarget().tabIndex = -1; + }); + + this.getActiveCellContent().tabIndex = 0; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Unable to initialize grid"); + } + } + + /** Get the focusable content of the active cell */ + private getActiveCellContent(): HTMLElement { + return this.grid[this.activeRow][this.activeCol].getFocusTarget(); + } + + /** Move focus via a delta against the currently active gridcell */ + private updateCellFocusByDelta(rowDelta: number, colDelta: number) { + const prevActive = this.getActiveCellContent(); + + this.activeCol += colDelta; + this.activeRow += rowDelta; + + // Row upper bound + if (this.activeRow >= this.grid.length) { + this.activeRow = this.grid.length - 1; + } + + // Row lower bound + if (this.activeRow < 0) { + this.activeRow = 0; + } + + // Column upper bound + if (this.activeCol >= this.grid[this.activeRow].length) { + if (this.activeRow < this.grid.length - 1) { + // Wrap to next row on right arrow + this.activeCol = 0; + this.activeRow += 1; + } else { + this.activeCol = this.grid[this.activeRow].length - 1; + } + } + + // Column lower bound + if (this.activeCol < 0) { + if (this.activeRow > 0) { + // Wrap to prev row on left arrow + this.activeRow -= 1; + this.activeCol = this.grid[this.activeRow].length - 1; + } else { + this.activeCol = 0; + } + } + + const nextActive = this.getActiveCellContent(); + nextActive.tabIndex = 0; + nextActive.focus(); + + if (nextActive !== prevActive) { + prevActive.tabIndex = -1; + } + } +} diff --git a/libs/components/src/a11y/a11y-row.directive.ts b/libs/components/src/a11y/a11y-row.directive.ts new file mode 100644 index 0000000000..e062eb2b5a --- /dev/null +++ b/libs/components/src/a11y/a11y-row.directive.ts @@ -0,0 +1,31 @@ +import { + AfterViewInit, + ContentChildren, + Directive, + HostBinding, + QueryList, + ViewChildren, +} from "@angular/core"; + +import { A11yCellDirective } from "./a11y-cell.directive"; + +@Directive({ + selector: "bitA11yRow", + standalone: true, +}) +export class A11yRowDirective implements AfterViewInit { + @HostBinding("attr.role") + role: "row" | null; + + cells: A11yCellDirective[]; + + @ViewChildren(A11yCellDirective) + private viewCells: QueryList; + + @ContentChildren(A11yCellDirective) + private contentCells: QueryList; + + ngAfterViewInit(): void { + this.cells = [...this.viewCells, ...this.contentCells]; + } +} diff --git a/libs/components/src/badge/badge.directive.ts b/libs/components/src/badge/badge.directive.ts index b81b9f80e2..acce4a18aa 100644 --- a/libs/components/src/badge/badge.directive.ts +++ b/libs/components/src/badge/badge.directive.ts @@ -1,5 +1,7 @@ import { Directive, ElementRef, HostBinding, Input } from "@angular/core"; +import { FocusableElement } from "../shared/focusable-element"; + export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info"; const styles: Record = { @@ -22,8 +24,9 @@ const hoverStyles: Record = { @Directive({ selector: "span[bitBadge], a[bitBadge], button[bitBadge]", + providers: [{ provide: FocusableElement, useExisting: BadgeDirective }], }) -export class BadgeDirective { +export class BadgeDirective implements FocusableElement { @HostBinding("class") get classList() { return [ "tw-inline-block", @@ -62,6 +65,10 @@ export class BadgeDirective { */ @Input() truncate = true; + getFocusTarget() { + return this.el.nativeElement; + } + private hasHoverEffects = false; constructor(private el: ElementRef) { diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 53e8032795..54f6dfda96 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -1,6 +1,7 @@ -import { Component, HostBinding, Input } from "@angular/core"; +import { Component, ElementRef, HostBinding, Input } from "@angular/core"; import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction"; +import { FocusableElement } from "../shared/focusable-element"; export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light"; @@ -123,9 +124,12 @@ const sizes: Record = { @Component({ selector: "button[bitIconButton]:not(button[bitButton])", templateUrl: "icon-button.component.html", - providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }], + providers: [ + { provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }, + { provide: FocusableElement, useExisting: BitIconButtonComponent }, + ], }) -export class BitIconButtonComponent implements ButtonLikeAbstraction { +export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { @Input("bitIconButton") icon: string; @Input() buttonType: IconButtonType; @@ -162,4 +166,10 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction { setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") { this.buttonType = value; } + + getFocusTarget() { + return this.elementRef.nativeElement; + } + + constructor(private elementRef: ElementRef) {} } diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 36185911a6..1e4a3a86ff 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -16,6 +16,7 @@ export * from "./form-field"; export * from "./icon-button"; export * from "./icon"; export * from "./input"; +export * from "./item"; export * from "./layout"; export * from "./link"; export * from "./menu"; diff --git a/libs/components/src/input/autofocus.directive.ts b/libs/components/src/input/autofocus.directive.ts index f8161ee6e0..625e7fbc92 100644 --- a/libs/components/src/input/autofocus.directive.ts +++ b/libs/components/src/input/autofocus.directive.ts @@ -3,12 +3,7 @@ import { take } from "rxjs/operators"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -/** - * Interface for implementing focusable components. Used by the AutofocusDirective. - */ -export abstract class FocusableElement { - focus: () => void; -} +import { FocusableElement } from "../shared/focusable-element"; /** * Directive to focus an element. @@ -46,7 +41,7 @@ export class AutofocusDirective { private focus() { if (this.focusableElement) { - this.focusableElement.focus(); + this.focusableElement.getFocusTarget().focus(); } else { this.el.nativeElement.focus(); } diff --git a/libs/components/src/item/index.ts b/libs/components/src/item/index.ts new file mode 100644 index 0000000000..56896cdc3c --- /dev/null +++ b/libs/components/src/item/index.ts @@ -0,0 +1 @@ +export * from "./item.module"; diff --git a/libs/components/src/item/item-action.component.ts b/libs/components/src/item/item-action.component.ts new file mode 100644 index 0000000000..8cabf5c5c2 --- /dev/null +++ b/libs/components/src/item/item-action.component.ts @@ -0,0 +1,12 @@ +import { Component } from "@angular/core"; + +import { A11yCellDirective } from "../a11y/a11y-cell.directive"; + +@Component({ + selector: "bit-item-action", + standalone: true, + imports: [], + template: ``, + providers: [{ provide: A11yCellDirective, useExisting: ItemActionComponent }], +}) +export class ItemActionComponent extends A11yCellDirective {} diff --git a/libs/components/src/item/item-content.component.html b/libs/components/src/item/item-content.component.html new file mode 100644 index 0000000000..d034a4a001 --- /dev/null +++ b/libs/components/src/item/item-content.component.html @@ -0,0 +1,16 @@ +
+ + +
+
+ +
+
+ +
+
+
+ +
+ +
diff --git a/libs/components/src/item/item-content.component.ts b/libs/components/src/item/item-content.component.ts new file mode 100644 index 0000000000..58a1198512 --- /dev/null +++ b/libs/components/src/item/item-content.component.ts @@ -0,0 +1,15 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +@Component({ + selector: "bit-item-content, [bit-item-content]", + standalone: true, + imports: [CommonModule], + templateUrl: `item-content.component.html`, + host: { + class: + "fvw-target tw-outline-none tw-text-main hover:tw-text-main hover:tw-no-underline tw-text-base tw-py-2 tw-px-4 tw-bg-transparent tw-w-full tw-border-none tw-flex tw-gap-4 tw-items-center tw-justify-between", + }, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ItemContentComponent {} diff --git a/libs/components/src/item/item-group.component.ts b/libs/components/src/item/item-group.component.ts new file mode 100644 index 0000000000..2a9a8275cc --- /dev/null +++ b/libs/components/src/item/item-group.component.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +@Component({ + selector: "bit-item-group", + standalone: true, + imports: [], + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tw-block", + }, +}) +export class ItemGroupComponent {} diff --git a/libs/components/src/item/item.component.html b/libs/components/src/item/item.component.html new file mode 100644 index 0000000000..0c91c6848e --- /dev/null +++ b/libs/components/src/item/item.component.html @@ -0,0 +1,21 @@ + +
+ + + + +
+ +
+
diff --git a/libs/components/src/item/item.component.ts b/libs/components/src/item/item.component.ts new file mode 100644 index 0000000000..4b7b57fa9f --- /dev/null +++ b/libs/components/src/item/item.component.ts @@ -0,0 +1,29 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, HostListener, signal } from "@angular/core"; + +import { A11yRowDirective } from "../a11y/a11y-row.directive"; + +import { ItemActionComponent } from "./item-action.component"; + +@Component({ + selector: "bit-item", + standalone: true, + imports: [CommonModule, ItemActionComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "item.component.html", + providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }], +}) +export class ItemComponent extends A11yRowDirective { + /** + * We have `:focus-within` and `:focus-visible` but no `:focus-visible-within` + */ + protected focusVisibleWithin = signal(false); + @HostListener("focusin", ["$event.target"]) + onFocusIn(target: HTMLElement) { + this.focusVisibleWithin.set(target.matches(".fvw-target:focus-visible")); + } + @HostListener("focusout") + onFocusOut() { + this.focusVisibleWithin.set(false); + } +} diff --git a/libs/components/src/item/item.mdx b/libs/components/src/item/item.mdx new file mode 100644 index 0000000000..8506de72bb --- /dev/null +++ b/libs/components/src/item/item.mdx @@ -0,0 +1,141 @@ +import { Meta, Story, Primary, Controls, Canvas } from "@storybook/addon-docs"; + +import * as stories from "./item.stories"; + + + +```ts +import { ItemModule } from "@bitwarden/components"; +``` + +# Item + +`` is a horizontal card that contains one or more interactive actions. + +It is a generic container that can be used for either standalone content, an alternative to tables, +or to list nav links. + + + + + +## Primary Content + +The primary content of an item is supplied by `bit-item-content`. + +### Content Types + +The content can be a button, anchor, or static container. + +```html + + Hi, I am a link. + + + + + + + + I'm just static :( + +``` + + + + + +### Content Slots + +`bit-item-content` contains the following slots to help position the content: + +| Slot | Description | +| ------------------ | --------------------------------------------------- | +| default | primary text or arbitrary content; fan favorite | +| `slot="secondary"` | supporting text; under the default slot | +| `slot="start"` | commonly an icon or avatar; before the default slot | +| `slot="end"` | commonly an icon; after the default slot | + +- Note: There is also an `end` slot within `bit-item` itself. Place + [interactive secondary actions](#secondary-actions) there, and place non-interactive content (such + as icons) in `bit-item-content` + +```html + + + +``` + + + + + +## Secondary Actions + +Secondary interactive actions can be placed in the item through the `"end"` slot, outside of +`bit-item-content`. + +Each action must be wrapped by ``. + +Actions are commonly icon buttons or badge buttons. + +```html + + + + + + + + + + + + + + + +``` + +## Item Groups + +Groups of items can be associated by wrapping them in the ``. + + + + + + + + + +### A11y + +Keyboard nav is currently disabled due to a bug when used within a virtual scroll viewport. + +Item groups utilize arrow-based keyboard navigation +([further reading here](https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/layout-grids/#kbd_label)). + +Use `aria-label` or `aria-labelledby` to give groups an accessible name. + +```html + + ... + ... + ... + +``` + +### Virtual Scrolling + + + + diff --git a/libs/components/src/item/item.module.ts b/libs/components/src/item/item.module.ts new file mode 100644 index 0000000000..226fed11d8 --- /dev/null +++ b/libs/components/src/item/item.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from "@angular/core"; + +import { ItemActionComponent } from "./item-action.component"; +import { ItemContentComponent } from "./item-content.component"; +import { ItemGroupComponent } from "./item-group.component"; +import { ItemComponent } from "./item.component"; + +@NgModule({ + imports: [ItemComponent, ItemContentComponent, ItemActionComponent, ItemGroupComponent], + exports: [ItemComponent, ItemContentComponent, ItemActionComponent, ItemGroupComponent], +}) +export class ItemModule {} diff --git a/libs/components/src/item/item.stories.ts b/libs/components/src/item/item.stories.ts new file mode 100644 index 0000000000..b9d8d6cc2e --- /dev/null +++ b/libs/components/src/item/item.stories.ts @@ -0,0 +1,326 @@ +import { ScrollingModule } from "@angular/cdk/scrolling"; +import { CommonModule } from "@angular/common"; +import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular"; + +import { A11yGridDirective } from "../a11y/a11y-grid.directive"; +import { AvatarModule } from "../avatar"; +import { BadgeModule } from "../badge"; +import { IconButtonModule } from "../icon-button"; +import { TypographyModule } from "../typography"; + +import { ItemActionComponent } from "./item-action.component"; +import { ItemContentComponent } from "./item-content.component"; +import { ItemGroupComponent } from "./item-group.component"; +import { ItemComponent } from "./item.component"; + +export default { + title: "Component Library/Item", + component: ItemComponent, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + ItemGroupComponent, + AvatarModule, + IconButtonModule, + BadgeModule, + TypographyModule, + ItemActionComponent, + ItemContentComponent, + A11yGridDirective, + ScrollingModule, + ], + }), + componentWrapperDecorator((story) => `
${story}
`), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + + + + + + + + + + + + `, + }), +}; + +export const ContentSlots: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + `, + }), +}; + +export const ContentTypes: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + Hi, I am a link. + + + + + + + + I'm just static :( + + + `, + }), +}; + +export const TextOverflow: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` +
TODO: Fix truncation
+ + + Helloooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo + + + `, + }), +}; + +export const MultipleActionList: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + }), +}; + +export const SingleActionList: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + Foobar + + + + + + Foobar + + + + + + Foobar + + + + + + Foobar + + + + + + Foobar + + + + + + Foobar + + + + + `, + }), +}; + +export const VirtualScrolling: Story = { + render: (_args) => ({ + props: { + data: Array.from(Array(100000).keys()), + }, + template: /*html*/ ` + + + + + + + + + + + + + + + + + + + + `, + }), +}; diff --git a/libs/components/src/search/search.component.ts b/libs/components/src/search/search.component.ts index a0f3eb363f..27170d5d7b 100644 --- a/libs/components/src/search/search.component.ts +++ b/libs/components/src/search/search.component.ts @@ -1,7 +1,7 @@ import { Component, ElementRef, Input, ViewChild } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { FocusableElement } from "../input/autofocus.directive"; +import { FocusableElement } from "../shared/focusable-element"; let nextId = 0; @@ -32,8 +32,8 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement { @Input() disabled: boolean; @Input() placeholder: string; - focus() { - this.input.nativeElement.focus(); + getFocusTarget() { + return this.input.nativeElement; } onChange(searchText: string) { diff --git a/libs/components/src/shared/focusable-element.ts b/libs/components/src/shared/focusable-element.ts new file mode 100644 index 0000000000..1ea422aa6f --- /dev/null +++ b/libs/components/src/shared/focusable-element.ts @@ -0,0 +1,8 @@ +/** + * Interface for implementing focusable components. + * + * Used by the `AutofocusDirective` and `A11yGridDirective`. + */ +export abstract class FocusableElement { + getFocusTarget: () => HTMLElement; +} diff --git a/libs/components/src/styles.scss b/libs/components/src/styles.scss index ae97838e09..7ddcb1b64b 100644 --- a/libs/components/src/styles.scss +++ b/libs/components/src/styles.scss @@ -49,6 +49,6 @@ $card-icons-base: "../../src/billing/images/cards/"; @import "multi-select/scss/bw.theme.scss"; // Workaround for https://bitwarden.atlassian.net/browse/CL-110 -#storybook-docs pre.prismjs { +.sbdocs-preview pre.prismjs { color: white; }