diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d23cfa58283..2550f0fddbe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,8 @@ apps/desktop/desktop_native @bitwarden/team-platform-dev apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-dev apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-dev +## No ownership for Cargo.toml to allow dependency updates +apps/desktop/desktop_native/Cargo.toml ## Auth team files ## apps/browser/src/auth @bitwarden/team-auth-dev @@ -119,6 +121,7 @@ apps/browser/src/autofill @bitwarden/team-autofill-dev apps/desktop/src/autofill @bitwarden/team-autofill-dev libs/common/src/autofill @bitwarden/team-autofill-dev apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev +apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-dev apps/desktop/desktop_native/windows-plugin-authenticator @bitwarden/team-autofill-dev # DuckDuckGo integration apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev diff --git a/.github/renovate.json5 b/.github/renovate.json5 index bde87563dd1..01361a97404 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -168,15 +168,20 @@ matchPackageNames: [ "@emotion/css", "@webcomponents/custom-elements", + "bitwarden-russh", "bytes", "concurrently", "cross-env", "del", + "ed25519", "lit", "patch-package", + "pkcs8", "prettier", "prettier-plugin-tailwindcss", "rimraf", + "ssh-encoding", + "ssh-key", "@storybook/web-components-webpack5", "tabbable", "tldts", @@ -210,31 +215,68 @@ "@types/node-forge", "@types/node-ipc", "@yao-pkg/pkg", + "anyhow", + "arboard", "babel-loader", + "base64", + "bindgen", "browserslist", + "byteorder", + "bytes", + "core-foundation", "copy-webpack-plugin", + "dirs", "electron", "electron-builder", "electron-log", "electron-reload", "electron-store", "electron-updater", + "embed_plist", + "futures", + "hex", + "homedir", "html-webpack-injector", "html-webpack-plugin", + "interprocess", "json5", + "keytar", + "libc", + "log", "lowdb", + "napi", + "napi-build", + "napi-derive", "node-forge", "node-ipc", + "oo7", + "oslog", + "pin-project", "pkg", + "rand", "rxjs", + "scopeguard", + "security-framework", + "security-framework-sys", + "serde", + "serde_json", + "simplelog", + "sysinfo", "tsconfig-paths-webpack-plugin", "type-fest", + "typenum", "typescript", "typescript-strict-plugin", + "uniffi", "webpack", "webpack-cli", "webpack-dev-server", "webpack-node-externals", + "widestring", + "windows", + "windows-registry", + "zbus", + "zbus_polkit", ], description: "Platform owned dependencies", commitMessagePrefix: "[deps] Platform:", @@ -352,7 +394,17 @@ reviewers: ["team:team-vault-dev"], }, { - matchPackageNames: ["@types/argon2-browser", "argon2", "argon2-browser", "big-integer"], + matchPackageNames: [ + "@types/argon2-browser", + "aes", + "argon2", + "argon2-browser", + "big-integer", + "cbc", + "rsa", + "russh-cryptovec", + "sha2", + ], description: "Key Management owned dependencies", commitMessagePrefix: "[deps] KM:", reviewers: ["team:team-key-management-dev"], diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 67b770230cd..86977b950f3 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -385,6 +385,15 @@ "editFolder": { "message": "Edit folder" }, + "editFolderWithName": { + "message": "Edit folder: $FOLDERNAME$", + "placeholders": { + "foldername": { + "content": "$1", + "example": "Social" + } + } + }, "newFolder": { "message": "New folder" }, @@ -1670,6 +1679,9 @@ "dragToSort": { "message": "Drag to sort" }, + "dragToReorder": { + "message": "Drag to reorder" + }, "cfTypeText": { "message": "Text" }, @@ -3326,9 +3338,6 @@ "loginWithDevice": { "message": "Log in with device" }, - "loginWithDeviceEnabledInfo": { - "message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?" - }, "fingerprintPhraseHeader": { "message": "Fingerprint phrase" }, @@ -3341,9 +3350,6 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptionsV1": { - "message": "View all log in options" - }, "notificationSentDevice": { "message": "A notification has been sent to your device." }, @@ -3534,9 +3540,6 @@ "adminApprovalRequestSentToAdmins": { "message": "Your request has been sent to your admin." }, - "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." - }, "troubleLoggingIn": { "message": "Trouble logging in?" }, @@ -4697,6 +4700,9 @@ } } }, + "reorderWebsiteUriButton": { + "message": "Reorder website URI. Use arrow key to move item up or down." + }, "reorderFieldUp": { "message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$", "placeholders": { @@ -5128,6 +5134,33 @@ "extraWide": { "message": "Extra wide" }, + "sshKeyWrongPassword": { + "message": "The password you entered is incorrect." + }, + "importSshKey": { + "message": "Import" + }, + "confirmSshKeyPassword": { + "message": "Confirm password" + }, + "enterSshKeyPasswordDesc": { + "message": "Enter the password for the SSH key." + }, + "enterSshKeyPassword": { + "message": "Enter password" + }, + "invalidSshKey": { + "message": "The SSH key is invalid" + }, + "sshKeyTypeUnsupported": { + "message": "The SSH key type is not supported" + }, + "importSshKeyFromClipboard": { + "message": "Import key from clipboard" + }, + "sshKeyImported": { + "message": "SSH key imported successfully" + }, "cannotRemoveViewOnlyCollections": { "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", "placeholders": { diff --git a/apps/browser/src/auth/popup/environment.component.html b/apps/browser/src/auth/popup/environment.component.html index ff19739548a..21e69fbbc39 100644 --- a/apps/browser/src/auth/popup/environment.component.html +++ b/apps/browser/src/auth/popup/environment.component.html @@ -1,7 +1,7 @@
- +

{{ "appName" | i18n }} diff --git a/apps/browser/src/auth/popup/home.component.html b/apps/browser/src/auth/popup/home.component.html deleted file mode 100644 index 08043cf88bb..00000000000 --- a/apps/browser/src/auth/popup/home.component.html +++ /dev/null @@ -1,36 +0,0 @@ - -
- -
diff --git a/apps/browser/src/auth/popup/home.component.ts b/apps/browser/src/auth/popup/home.component.ts deleted file mode 100644 index 0c4510204d1..00000000000 --- a/apps/browser/src/auth/popup/home.component.ts +++ /dev/null @@ -1,130 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, firstValueFrom, switchMap, takeUntil, tap } from "rxjs"; - -import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component"; -import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ToastService } from "@bitwarden/components"; - -import { AccountSwitcherService } from "./account-switching/services/account-switcher.service"; - -@Component({ - selector: "app-home", - templateUrl: "home.component.html", -}) -export class HomeComponent implements OnInit, OnDestroy { - @ViewChild(EnvironmentSelectorComponent, { static: true }) - environmentSelector!: EnvironmentSelectorComponent; - private destroyed$: Subject = new Subject(); - - loginInitiated = false; - formGroup = this.formBuilder.group({ - email: ["", [Validators.required, Validators.email]], - rememberEmail: [false], - }); - - constructor( - protected platformUtilsService: PlatformUtilsService, - private formBuilder: FormBuilder, - private router: Router, - private i18nService: I18nService, - private loginEmailService: LoginEmailServiceAbstraction, - private accountSwitcherService: AccountSwitcherService, - private toastService: ToastService, - private configService: ConfigService, - private route: ActivatedRoute, - ) {} - - async ngOnInit(): Promise { - this.listenForUnauthUiRefreshFlagChanges(); - - const email = await firstValueFrom(this.loginEmailService.loginEmail$); - const rememberEmail = this.loginEmailService.getRememberEmail(); - - if (email != null) { - this.formGroup.patchValue({ email, rememberEmail }); - } else { - const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$); - - if (storedEmail != null) { - this.formGroup.patchValue({ email: storedEmail, rememberEmail: true }); - } - } - - this.environmentSelector.onOpenSelfHostedSettings - .pipe( - switchMap(async () => { - await this.setLoginEmailValues(); - await this.router.navigate(["environment"]); - }), - takeUntil(this.destroyed$), - ) - .subscribe(); - } - - ngOnDestroy(): void { - this.destroyed$.next(); - this.destroyed$.complete(); - } - - private listenForUnauthUiRefreshFlagChanges() { - this.configService - .getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh) - .pipe( - tap(async (flag) => { - // If the flag is turned ON, we must force a reload to ensure the correct UI is shown - if (flag) { - const qParams = await firstValueFrom(this.route.queryParams); - - const uniqueQueryParams = { - ...qParams, - // adding a unique timestamp to the query params to force a reload - t: new Date().getTime().toString(), - }; - - await this.router.navigate(["/login"], { - queryParams: uniqueQueryParams, - }); - } - }), - takeUntil(this.destroyed$), - ) - .subscribe(); - } - - get availableAccounts$() { - return this.accountSwitcherService.availableAccounts$; - } - - async submit() { - this.formGroup.markAllAsTouched(); - - if (this.formGroup.invalid) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccured"), - message: this.i18nService.t("invalidEmail"), - }); - return; - } - - await this.setLoginEmailValues(); - await this.router.navigate(["login"], { - queryParams: { email: this.formGroup.controls.email.value }, - }); - } - - async setLoginEmailValues() { - // Note: Browser saves email settings here instead of the login component - this.loginEmailService.setRememberEmail(this.formGroup.controls.rememberEmail.value); - await this.loginEmailService.setLoginEmail(this.formGroup.controls.email.value); - await this.loginEmailService.saveEmailSettings(); - } -} diff --git a/apps/browser/src/auth/popup/login-v1.component.html b/apps/browser/src/auth/popup/login-v1.component.html deleted file mode 100644 index 145a9cbc754..00000000000 --- a/apps/browser/src/auth/popup/login-v1.component.html +++ /dev/null @@ -1,81 +0,0 @@ -
-
-

- {{ "logIn" | i18n }} -

-
-
-
-
-
-
- - - - -
-
- -
-
-
- -
-
- -
- -
-
diff --git a/apps/browser/src/auth/popup/login-v1.component.ts b/apps/browser/src/auth/popup/login-v1.component.ts deleted file mode 100644 index b2c52f248c6..00000000000 --- a/apps/browser/src/auth/popup/login-v1.component.ts +++ /dev/null @@ -1,142 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, NgZone, OnInit } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; - -import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component"; -import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, -} from "@bitwarden/auth/common"; -import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { ToastService } from "@bitwarden/components"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; - -@Component({ - selector: "app-login", - templateUrl: "login-v1.component.html", -}) -export class LoginComponentV1 extends BaseLoginComponent implements OnInit { - constructor( - devicesApiService: DevicesApiServiceAbstraction, - appIdService: AppIdService, - loginStrategyService: LoginStrategyServiceAbstraction, - router: Router, - protected platformUtilsService: PlatformUtilsService, - protected i18nService: I18nService, - protected stateService: StateService, - protected environmentService: EnvironmentService, - protected passwordGenerationService: PasswordGenerationServiceAbstraction, - protected cryptoFunctionService: CryptoFunctionService, - syncService: SyncService, - logService: LogService, - ngZone: NgZone, - formBuilder: FormBuilder, - formValidationErrorService: FormValidationErrorsService, - route: ActivatedRoute, - loginEmailService: LoginEmailServiceAbstraction, - ssoLoginService: SsoLoginServiceAbstraction, - toastService: ToastService, - ) { - super( - devicesApiService, - appIdService, - loginStrategyService, - router, - platformUtilsService, - i18nService, - stateService, - environmentService, - passwordGenerationService, - cryptoFunctionService, - logService, - ngZone, - formBuilder, - formValidationErrorService, - route, - loginEmailService, - ssoLoginService, - toastService, - ); - this.onSuccessfulLogin = async () => { - await syncService.fullSync(true); - }; - this.successRoute = "/tabs/vault"; - } - - async ngOnInit(): Promise { - await super.ngOnInit(); - await this.validateEmail(); - } - - settings() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["environment"]); - } - - async launchSsoBrowser() { - // Save off email for SSO - await this.ssoLoginService.setSsoEmail(this.formGroup.value.email); - - // Generate necessary sso params - const passwordOptions: any = { - type: "password", - length: 64, - uppercase: true, - lowercase: true, - numbers: true, - special: false, - }; - - const state = - (await this.passwordGenerationService.generatePassword(passwordOptions)) + - ":clientId=browser"; - const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); - const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256"); - const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); - - await this.ssoLoginService.setCodeVerifier(codeVerifier); - await this.ssoLoginService.setSsoState(state); - - const env = await firstValueFrom(this.environmentService.environment$); - let url = env.getWebVaultUrl(); - if (url == null) { - url = "https://vault.bitwarden.com"; - } - - const redirectUri = url + "/sso-connector.html"; - - // Launch browser - this.platformUtilsService.launchUri( - url + - "/#/sso?clientId=browser" + - "&redirectUri=" + - encodeURIComponent(redirectUri) + - "&state=" + - state + - "&codeChallenge=" + - codeChallenge + - "&email=" + - encodeURIComponent(this.formGroup.controls.email.value), - ); - } - - async saveEmailSettings() { - // values should be saved on home component - return; - } -} diff --git a/apps/browser/src/auth/popup/login-via-auth-request-v1.component.html b/apps/browser/src/auth/popup/login-via-auth-request-v1.component.html deleted file mode 100644 index 34c0cbe9614..00000000000 --- a/apps/browser/src/auth/popup/login-via-auth-request-v1.component.html +++ /dev/null @@ -1,68 +0,0 @@ - diff --git a/apps/browser/src/auth/popup/login-via-auth-request-v1.component.ts b/apps/browser/src/auth/popup/login-via-auth-request-v1.component.ts deleted file mode 100644 index 66c69d0a41a..00000000000 --- a/apps/browser/src/auth/popup/login-via-auth-request-v1.component.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Location } from "@angular/common"; -import { Component } from "@angular/core"; -import { Router } from "@angular/router"; - -import { LoginViaAuthRequestComponentV1 as BaseLoginViaAuthRequestComponentV1 } from "@bitwarden/angular/auth/components/login-via-auth-request-v1.component"; -import { - AuthRequestServiceAbstraction, - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, -} from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; -import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { ToastService } from "@bitwarden/components"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { KeyService } from "@bitwarden/key-management"; - -@Component({ - selector: "app-login-via-auth-request", - templateUrl: "login-via-auth-request-v1.component.html", -}) -export class LoginViaAuthRequestComponentV1 extends BaseLoginViaAuthRequestComponentV1 { - constructor( - router: Router, - keyService: KeyService, - cryptoFunctionService: CryptoFunctionService, - appIdService: AppIdService, - passwordGenerationService: PasswordGenerationServiceAbstraction, - apiService: ApiService, - authService: AuthService, - logService: LogService, - environmentService: EnvironmentService, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - anonymousHubService: AnonymousHubService, - validationService: ValidationService, - loginEmailService: LoginEmailServiceAbstraction, - syncService: SyncService, - deviceTrustService: DeviceTrustServiceAbstraction, - authRequestService: AuthRequestServiceAbstraction, - loginStrategyService: LoginStrategyServiceAbstraction, - accountService: AccountService, - private location: Location, - toastService: ToastService, - ) { - super( - router, - keyService, - cryptoFunctionService, - appIdService, - passwordGenerationService, - apiService, - authService, - logService, - environmentService, - i18nService, - platformUtilsService, - anonymousHubService, - validationService, - accountService, - loginEmailService, - deviceTrustService, - authRequestService, - loginStrategyService, - toastService, - ); - this.onSuccessfulLogin = async () => { - await syncService.fullSync(true); - }; - } - - protected back() { - this.location.back(); - } -} diff --git a/apps/browser/src/auth/popup/set-password.component.html b/apps/browser/src/auth/popup/set-password.component.html index 6261608c345..71a2e3ac588 100644 --- a/apps/browser/src/auth/popup/set-password.component.html +++ b/apps/browser/src/auth/popup/set-password.component.html @@ -1,7 +1,7 @@
- +

{{ "setMasterPassword" | i18n }} diff --git a/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts b/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts index 0b47fa4287e..b2c20ba2849 100644 --- a/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts +++ b/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts @@ -72,7 +72,7 @@ describe("AuthPopoutWindow", () => { it("closes any existing popup window types that are open to the login extension route", async () => { const loginTab = createChromeTabMock({ - url: chrome.runtime.getURL("popup/index.html#/home"), + url: chrome.runtime.getURL("popup/index.html#/login"), }); jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([loginTab]); jest.spyOn(BrowserApi, "removeWindow"); diff --git a/apps/browser/src/auth/popup/utils/auth-popout-window.ts b/apps/browser/src/auth/popup/utils/auth-popout-window.ts index 2f135038315..0646b684b22 100644 --- a/apps/browser/src/auth/popup/utils/auth-popout-window.ts +++ b/apps/browser/src/auth/popup/utils/auth-popout-window.ts @@ -13,7 +13,7 @@ const AuthPopoutType = { const extensionUnlockUrls = new Set([ chrome.runtime.getURL("popup/index.html#/lock"), - chrome.runtime.getURL("popup/index.html#/home"), + chrome.runtime.getURL("popup/index.html#/login"), ]); /** diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index f0b73838ba4..7bd6c93bb64 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -364,7 +364,7 @@ export class AutofillComponent implements OnInit { return; } - BrowserApi.updateDefaultBrowserAutofillSettings(!this.defaultBrowserAutofillDisabled); + await BrowserApi.updateDefaultBrowserAutofillSettings(!this.defaultBrowserAutofillDisabled); } private handleOverrideDialogAccept = async () => { diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts index dc59e05a18b..f3047947c82 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts @@ -21,8 +21,7 @@ describe("InlineMenuFieldQualificationService", () => { }); describe("isFieldForLoginForm", () => { - it("does not disqualify totp fields for premium users with flag set to true", () => { - inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = true; + it("does not disqualify totp fields for premium users", () => { inlineMenuFieldQualificationService["premiumEnabled"] = true; const field = mock({ type: "text", @@ -37,24 +36,7 @@ describe("InlineMenuFieldQualificationService", () => { ); }); - it("disqualifies totp fields for premium users with flag set to false", () => { - inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = false; - inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = true; - const field = mock({ - type: "text", - autoCompleteType: "one-time-code", - htmlName: "totp", - htmlID: "totp", - placeholder: "totp", - }); - - expect(inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails)).toBe( - false, - ); - }); - - it("disqualifies totp fields for non-premium users with flag set to true", () => { - inlineMenuFieldQualificationService["inlineMenuTotpFeatureFlag"] = true; + it("disqualifies totp fields for non-premium users", () => { inlineMenuFieldQualificationService["premiumEnabled"] = false; const field = mock({ type: "text", diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index dff40a3ab93..046381d956c 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -151,17 +151,14 @@ export class InlineMenuFieldQualificationService ]); private totpFieldAutocompleteValue = "one-time-code"; private inlineMenuFieldQualificationFlagSet = false; - private inlineMenuTotpFeatureFlag = false; private premiumEnabled = false; constructor() { void Promise.all([ sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag"), - sendExtensionMessage("getInlineMenuTotpFeatureFlag"), sendExtensionMessage("getUserPremiumStatus"), - ]).then(([fieldQualificationFlag, totpFeatureFlag, premiumStatus]) => { + ]).then(([fieldQualificationFlag, premiumStatus]) => { this.inlineMenuFieldQualificationFlagSet = !!fieldQualificationFlag?.result; - this.inlineMenuTotpFeatureFlag = !!totpFeatureFlag?.result; this.premiumEnabled = !!premiumStatus?.result; }); } @@ -180,7 +177,7 @@ export class InlineMenuFieldQualificationService /** * Totp inline menu is available only for premium users. */ - if (this.inlineMenuTotpFeatureFlag && this.premiumEnabled) { + if (this.premiumEnabled) { const isTotpField = this.isTotpField(field); // Autofill does not fill totp inputs with a "password" `type` attribute value const passwordType = field.type === "password"; diff --git a/apps/browser/src/autofill/spec/autofill-mocks.ts b/apps/browser/src/autofill/spec/autofill-mocks.ts index 6e175906d30..8279c98c5b4 100644 --- a/apps/browser/src/autofill/spec/autofill-mocks.ts +++ b/apps/browser/src/autofill/spec/autofill-mocks.ts @@ -103,6 +103,7 @@ export function createChromeTabMock(customFields = {}): chrome.tabs.Tab { selected: true, discarded: false, autoDiscardable: false, + frozen: false, groupId: 2, url: "https://jest-testing-website.com", ...customFields, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index f8b5f63710a..faee95a324e 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1011,6 +1011,7 @@ export default class MainBackground { this.encryptService, this.pinService, this.accountService, + this.sdkService, ); this.individualVaultExportService = new IndividualVaultExportService( diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index a5fea0651fc..32e7432174a 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -78,7 +78,6 @@ export default class RuntimeBackground { BiometricsCommands.GetBiometricsStatusForUser, "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag", "getInlineMenuFieldQualificationFeatureFlag", - "getInlineMenuTotpFeatureFlag", "getUserPremiumStatus", ]; @@ -217,9 +216,6 @@ export default class RuntimeBackground { ); return result; } - case "getInlineMenuTotpFeatureFlag": { - return await this.configService.getFeatureFlag(FeatureFlag.InlineMenuTotp); - } } } diff --git a/apps/browser/src/platform/browser/browser-api.spec.ts b/apps/browser/src/platform/browser/browser-api.spec.ts index 6e8a0f3002d..f6bf7d4069b 100644 --- a/apps/browser/src/platform/browser/browser-api.spec.ts +++ b/apps/browser/src/platform/browser/browser-api.spec.ts @@ -2,6 +2,8 @@ import { mock } from "jest-mock-extended"; import { BrowserApi } from "./browser-api"; +type ChromeSettingsGet = chrome.types.ChromeSetting["get"]; + describe("BrowserApi", () => { const executeScriptResult = ["value"]; @@ -468,19 +470,23 @@ describe("BrowserApi", () => { describe("browserAutofillSettingsOverridden", () => { it("returns true if the browser autofill settings are overridden", async () => { - const expectedDetails = { - value: false, - levelOfControl: "controlled_by_this_extension", - } as chrome.types.ChromeSettingGetResultDetails; - chrome.privacy.services.autofillAddressEnabled.get = jest.fn((details, callback) => - callback(expectedDetails), - ); - chrome.privacy.services.autofillCreditCardEnabled.get = jest.fn((details, callback) => - callback(expectedDetails), - ); - chrome.privacy.services.passwordSavingEnabled.get = jest.fn((details, callback) => - callback(expectedDetails), - ); + const mockFn = jest.fn< + void, + [ + details: chrome.types.ChromeSettingGetDetails, + callback: (details: chrome.types.ChromeSettingGetResult) => void, + ], + never + >((details, callback) => { + callback({ + value: false, + levelOfControl: "controlled_by_this_extension", + }); + }); + chrome.privacy.services.autofillAddressEnabled.get = mockFn as unknown as ChromeSettingsGet; + chrome.privacy.services.autofillCreditCardEnabled.get = + mockFn as unknown as ChromeSettingsGet; + chrome.privacy.services.passwordSavingEnabled.get = mockFn as unknown as ChromeSettingsGet; const result = await BrowserApi.browserAutofillSettingsOverridden(); @@ -488,19 +494,24 @@ describe("BrowserApi", () => { }); it("returns false if the browser autofill settings are not overridden", async () => { - const expectedDetails = { - value: true, - levelOfControl: "controlled_by_this_extension", - } as chrome.types.ChromeSettingGetResultDetails; - chrome.privacy.services.autofillAddressEnabled.get = jest.fn((details, callback) => - callback(expectedDetails), - ); - chrome.privacy.services.autofillCreditCardEnabled.get = jest.fn((details, callback) => - callback(expectedDetails), - ); - chrome.privacy.services.passwordSavingEnabled.get = jest.fn((details, callback) => - callback(expectedDetails), - ); + const mockFn = jest.fn< + void, + [ + details: chrome.types.ChromeSettingGetDetails, + callback: (details: chrome.types.ChromeSettingGetResult) => void, + ], + never + >((details, callback) => { + callback({ + value: true, + levelOfControl: "controlled_by_this_extension", + }); + }); + + chrome.privacy.services.autofillAddressEnabled.get = mockFn as unknown as ChromeSettingsGet; + chrome.privacy.services.autofillCreditCardEnabled.get = + mockFn as unknown as ChromeSettingsGet; + chrome.privacy.services.passwordSavingEnabled.get = mockFn as unknown as ChromeSettingsGet; const result = await BrowserApi.browserAutofillSettingsOverridden(); @@ -508,19 +519,23 @@ describe("BrowserApi", () => { }); it("returns false if the browser autofill settings are not controlled by the extension", async () => { - const expectedDetails = { - value: false, - levelOfControl: "controlled_by_other_extensions", - } as chrome.types.ChromeSettingGetResultDetails; - chrome.privacy.services.autofillAddressEnabled.get = jest.fn((details, callback) => - callback(expectedDetails), - ); - chrome.privacy.services.autofillCreditCardEnabled.get = jest.fn((details, callback) => - callback(expectedDetails), - ); - chrome.privacy.services.passwordSavingEnabled.get = jest.fn((details, callback) => - callback(expectedDetails), - ); + const mockFn = jest.fn< + void, + [ + details: chrome.types.ChromeSettingGetDetails, + callback: (details: chrome.types.ChromeSettingGetResult) => void, + ], + never + >((details, callback) => { + callback({ + value: false, + levelOfControl: "controlled_by_other_extensions", + }); + }); + chrome.privacy.services.autofillAddressEnabled.get = mockFn as unknown as ChromeSettingsGet; + chrome.privacy.services.autofillCreditCardEnabled.get = + mockFn as unknown as ChromeSettingsGet; + chrome.privacy.services.passwordSavingEnabled.get = mockFn as unknown as ChromeSettingsGet; const result = await BrowserApi.browserAutofillSettingsOverridden(); diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 293fca4d029..ec16c883bfb 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -504,7 +504,9 @@ export class BrowserApi { * * @param permissions - The permissions to check. */ - static async permissionsGranted(permissions: string[]): Promise { + static async permissionsGranted( + permissions: chrome.runtime.ManifestPermissions[], + ): Promise { return new Promise((resolve) => chrome.permissions.contains({ permissions }, (result) => resolve(result)), ); @@ -594,7 +596,7 @@ export class BrowserApi { * Identifies if the browser autofill settings are overridden by the extension. */ static async browserAutofillSettingsOverridden(): Promise { - const checkOverrideStatus = (details: chrome.types.ChromeSettingGetResultDetails) => + const checkOverrideStatus = (details: chrome.types.ChromeSettingGetResult) => details.levelOfControl === "controlled_by_this_extension" && !details.value; const autofillAddressOverridden: boolean = await new Promise((resolve) => @@ -623,10 +625,10 @@ export class BrowserApi { * * @param value - Determines whether to enable or disable the autofill settings. */ - static updateDefaultBrowserAutofillSettings(value: boolean) { - chrome.privacy.services.autofillAddressEnabled.set({ value }); - chrome.privacy.services.autofillCreditCardEnabled.set({ value }); - chrome.privacy.services.passwordSavingEnabled.set({ value }); + static async updateDefaultBrowserAutofillSettings(value: boolean) { + await chrome.privacy.services.autofillAddressEnabled.set({ value }); + await chrome.privacy.services.autofillCreditCardEnabled.set({ value }); + await chrome.privacy.services.passwordSavingEnabled.set({ value }); } /** diff --git a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts index 2c29f1e5763..457198eaa4e 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts @@ -64,8 +64,16 @@ export class PopupViewCacheService implements ViewCacheService { filter((e) => e instanceof NavigationEnd), /** Skip the first navigation triggered by `popupRouterCacheGuard` */ skip(1), + filter((e: NavigationEnd) => + // viewing/editing a cipher and navigating back to the vault list should not clear the cache + ["/view-cipher", "/edit-cipher", "/tabs/vault"].every( + (route) => !e.urlAfterRedirects.startsWith(route), + ), + ), ) - .subscribe(() => this.clearState()); + .subscribe((e) => { + return this.clearState(); + }); } /** diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 8f5d754b554..b33940a68d2 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -7,7 +7,6 @@ import { EnvironmentSelectorRouteData, ExtensionDefaultOverlayPosition, } from "@bitwarden/angular/auth/components/environment-selector.component"; -import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect"; import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { activeAuthGuard, @@ -58,15 +57,9 @@ import { ExtensionAnonLayoutWrapperComponent, ExtensionAnonLayoutWrapperData, } from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component"; -import { HintComponent } from "../auth/popup/hint.component"; -import { HomeComponent } from "../auth/popup/home.component"; -import { LoginDecryptionOptionsComponentV1 } from "../auth/popup/login-decryption-options/login-decryption-options-v1.component"; -import { LoginComponentV1 } from "../auth/popup/login-v1.component"; -import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; -import { SsoComponentV1 } from "../auth/popup/sso-v1.component"; import { TwoFactorOptionsComponentV1 } from "../auth/popup/two-factor-options-v1.component"; import { TwoFactorComponentV1 } from "../auth/popup/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; @@ -131,20 +124,19 @@ const routes: Routes = [ children: [], // Children lets us have an empty component. canActivate: [ popupRouterCacheGuard, - redirectGuard({ loggedIn: "/tabs/current", loggedOut: "/home", locked: "/lock" }), + redirectGuard({ loggedIn: "/tabs/current", loggedOut: "/login", locked: "/lock" }), ], }, + { + path: "home", + redirectTo: "login", + pathMatch: "full", + }, { path: "vault", redirectTo: "/tabs/vault", pathMatch: "full", }, - { - path: "home", - component: HomeComponent, - canActivate: [unauthGuardFn(unauthRouteOverrides), unauthUiRefreshRedirect("/login")], - data: { elevation: 1 } satisfies RouteDataProperties, - }, { path: "fido2", component: Fido2Component, @@ -206,40 +198,6 @@ const routes: Routes = [ canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { elevation: 1 } satisfies RouteDataProperties, }, - ...unauthUiRefreshSwap( - SsoComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "sso", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { elevation: 1 } satisfies RouteDataProperties, - }, - { - path: "sso", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { - pageIcon: VaultIcon, - pageTitle: { - key: "enterpriseSingleSignOn", - }, - pageSubtitle: { - key: "singleSignOnEnterOrgIdentifierText", - }, - elevation: 1, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - children: [ - { path: "", component: SsoComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - data: { - overlayPosition: ExtensionDefaultOverlayPosition, - } satisfies EnvironmentSelectorRouteData, - }, - ], - }, - ), { path: "device-verification", component: ExtensionAnonLayoutWrapperComponent, @@ -420,158 +378,7 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, - ...unauthUiRefreshSwap( - LoginViaAuthRequestComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "login-with-device", - data: { elevation: 1 } satisfies RouteDataProperties, - }, - { - path: "login-with-device", - data: { - pageIcon: DevicesIcon, - pageTitle: { - key: "logInRequestSent", - }, - pageSubtitle: { - key: "aNotificationWasSentToYourDevice", - }, - showLogo: false, - showBackButton: true, - elevation: 1, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - children: [ - { path: "", component: LoginViaAuthRequestComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginViaAuthRequestComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "admin-approval-requested", - data: { elevation: 1 } satisfies RouteDataProperties, - }, - { - path: "admin-approval-requested", - data: { - pageIcon: DevicesIcon, - pageTitle: { - key: "adminApprovalRequested", - }, - pageSubtitle: { - key: "adminApprovalRequestSentToAdmins", - }, - showLogo: false, - showBackButton: true, - elevation: 1, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - children: [{ path: "", component: LoginViaAuthRequestComponent }], - }, - ), - ...unauthUiRefreshSwap( - HintComponent, - ExtensionAnonLayoutWrapperComponent, - { - path: "hint", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { - elevation: 1, - } satisfies RouteDataProperties, - }, - { - path: "", - children: [ - { - path: "hint", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { - pageTitle: { - key: "requestPasswordHint", - }, - pageSubtitle: { - key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", - }, - pageIcon: UserLockIcon, - showBackButton: true, - elevation: 1, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - children: [ - { path: "", component: PasswordHintComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - data: { - overlayPosition: ExtensionDefaultOverlayPosition, - } satisfies EnvironmentSelectorRouteData, - }, - ], - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "login", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { elevation: 1 }, - }, - { - path: "", - children: [ - { - path: "login", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { - pageIcon: VaultIcon, - pageTitle: { - key: "logInToBitwarden", - }, - elevation: 1, - showAcctSwitcher: true, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - children: [ - { path: "", component: LoginComponent }, - { path: "", component: LoginSecondaryContentComponent, outlet: "secondary" }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - data: { - overlayPosition: ExtensionDefaultOverlayPosition, - } satisfies EnvironmentSelectorRouteData, - }, - ], - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginDecryptionOptionsComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "login-initiated", - canActivate: [tdeDecryptionRequiredGuard()], - data: { elevation: 1 } satisfies RouteDataProperties, - }, - { - path: "login-initiated", - canActivate: [tdeDecryptionRequiredGuard()], - data: { - pageIcon: DevicesIcon, - }, - children: [{ path: "", component: LoginDecryptionOptionsComponent }], - }, - ), + { path: "", component: ExtensionAnonLayoutWrapperComponent, @@ -597,7 +404,7 @@ const routes: Routes = [ component: RegistrationStartSecondaryComponent, outlet: "secondary", data: { - loginRoute: "/home", + loginRoute: "/login", } satisfies RegistrationStartSecondaryComponentData, }, ], @@ -617,6 +424,127 @@ const routes: Routes = [ }, ], }, + { + path: "login", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { + pageIcon: VaultIcon, + pageTitle: { + key: "logInToBitwarden", + }, + elevation: 1, + showAcctSwitcher: true, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { path: "", component: LoginComponent }, + { path: "", component: LoginSecondaryContentComponent, outlet: "secondary" }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + data: { + overlayPosition: ExtensionDefaultOverlayPosition, + } satisfies EnvironmentSelectorRouteData, + }, + ], + }, + { + path: "sso", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { + pageIcon: VaultIcon, + pageTitle: { + key: "enterpriseSingleSignOn", + }, + pageSubtitle: { + key: "singleSignOnEnterOrgIdentifierText", + }, + elevation: 1, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { path: "", component: SsoComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + data: { + overlayPosition: ExtensionDefaultOverlayPosition, + } satisfies EnvironmentSelectorRouteData, + }, + ], + }, + { + path: "login-with-device", + data: { + pageIcon: DevicesIcon, + pageTitle: { + key: "logInRequestSent", + }, + pageSubtitle: { + key: "aNotificationWasSentToYourDevice", + }, + showBackButton: true, + elevation: 1, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { path: "", component: LoginViaAuthRequestComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "hint", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { + pageTitle: { + key: "requestPasswordHint", + }, + pageSubtitle: { + key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", + }, + pageIcon: UserLockIcon, + showBackButton: true, + elevation: 1, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { path: "", component: PasswordHintComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + data: { + overlayPosition: ExtensionDefaultOverlayPosition, + } satisfies EnvironmentSelectorRouteData, + }, + ], + }, + { + path: "admin-approval-requested", + data: { + pageIcon: DevicesIcon, + pageTitle: { + key: "adminApprovalRequested", + }, + pageSubtitle: { + key: "adminApprovalRequestSentToAdmins", + }, + showLogo: false, + showBackButton: true, + elevation: 1, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [{ path: "", component: LoginViaAuthRequestComponent }], + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, { path: "lock", canActivate: [lockGuard()], diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 9a3b6429e61..6a08bf007bb 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -1,9 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs"; +import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { LogoutReason } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -68,7 +70,10 @@ export class AppComponent implements OnInit, OnDestroy { private animationControlService: AnimationControlService, private biometricStateService: BiometricStateService, private biometricsService: BiometricsService, - ) {} + private deviceTrustToastService: DeviceTrustToastService, + ) { + this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); + } async ngOnInit() { initPopupClosedListener(); @@ -113,9 +118,7 @@ export class AppComponent implements OnInit, OnDestroy { }); this.changeDetectorRef.detectChanges(); } else if (msg.command === "authBlocked" || msg.command === "goHome") { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["home"]); + await this.router.navigate(["login"]); } else if ( msg.command === "locked" && (msg.userId == null || msg.userId == this.activeUserId) diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index a190cb134ac..72117a2f900 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -22,10 +22,7 @@ import { CurrentAccountComponent } from "../auth/popup/account-switching/current import { EnvironmentComponent } from "../auth/popup/environment.component"; import { ExtensionAnonLayoutWrapperComponent } from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component"; import { HintComponent } from "../auth/popup/hint.component"; -import { HomeComponent } from "../auth/popup/home.component"; import { LoginDecryptionOptionsComponentV1 } from "../auth/popup/login-decryption-options/login-decryption-options-v1.component"; -import { LoginComponentV1 } from "../auth/popup/login-v1.component"; -import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; @@ -98,9 +95,6 @@ import "../platform/popup/locales"; ColorPasswordCountPipe, EnvironmentComponent, HintComponent, - HomeComponent, - LoginViaAuthRequestComponentV1, - LoginComponentV1, LoginDecryptionOptionsComponentV1, SetPasswordComponent, SsoComponentV1, diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss index bfa037eea23..7a8d39c6f3e 100644 --- a/apps/browser/src/popup/scss/base.scss +++ b/apps/browser/src/popup/scss/base.scss @@ -448,38 +448,6 @@ main:not(popup-page main) { width: 100%; } -.login-with-device { - .fingerprint-phrase-header { - padding-top: 1rem; - display: block; - } - - @include themify($themes) { - .fingerprint-text { - color: themed("codeColor"); - font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", - monospace; - padding: 1rem 0; - } - } - - .resend-notification { - padding-bottom: 1rem; - a { - cursor: pointer; - } - } - - .footer { - padding-top: 1rem; - - a { - padding-top: 1rem; - display: block; - } - } -} - #login-initiated { .margin-auto { margin: auto; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index d49f48c0c64..5a2adfcf62b 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -130,7 +130,11 @@ import { KeyService, } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { + DefaultSshImportPromptService, + PasswordRepromptService, + SshImportPromptService, +} from "@bitwarden/vault"; import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service"; import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; @@ -653,6 +657,11 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionLoginDecryptionOptionsService, deps: [MessagingServiceAbstraction, Router], }), + safeProvider({ + provide: SshImportPromptService, + useClass: DefaultSshImportPromptService, + deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html index 071873b40c9..40f00ab4332 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -6,6 +6,7 @@ (onRefresh)="refreshCurrentTab()" [description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null" showAutofillButton + [disableDescriptionMargin]="showEmptyAutofillTip$ | async" [primaryActionAutofill]="clickItemsToAutofillVaultView" [groupByType]="groupByType()" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html index 4d617ff7786..c952260a9a9 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html @@ -27,12 +27,7 @@ {{ "note" | i18n }} - + {{ "typeSshKey" | i18n }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts index db3fff04bbb..bb452b89c7b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts @@ -5,8 +5,6 @@ import { Component, Input, OnInit } from "@angular/core"; import { Router, RouterLink } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -40,13 +38,9 @@ export class NewItemDropdownV2Component implements OnInit { constructor( private router: Router, private dialogService: DialogService, - private configService: ConfigService, ) {} - sshKeysEnabled = false; - async ngOnInit() { - this.sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem); this.tab = await BrowserApi.getTabFromCurrentWindow(); } diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html b/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html index a6abe8ed3ac..6cc60eed6d5 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html @@ -17,6 +17,7 @@ -
+
{{ description }}
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index cb758e7a48d..9d70c0ba236 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -245,6 +245,12 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit { @Input({ transform: booleanAttribute }) disableSectionMargin: boolean = false; + /** + * Remove the description margin + */ + @Input({ transform: booleanAttribute }) + disableDescriptionMargin: boolean = false; + /** * The tooltip text for the organization icon for ciphers that belong to an organization. * @param cipher diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.html b/apps/browser/src/vault/popup/settings/folders-v2.component.html index 21e00757a29..35a0fbec0a9 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.html +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.html @@ -19,7 +19,7 @@ slot="end" type="button" (click)="openAddEditFolderDialog(folder)" - [appA11yTitle]="'editFolder' | i18n" + [appA11yTitle]="'editFolderWithName' | i18n: folder.name" bitIconButton="bwi-pencil-square" class="tw-self-end" data-testid="edit-folder-button" diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 985816fb0dd..8fae8302bc9 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -21,6 +21,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; @@ -58,6 +59,7 @@ export class LoginCommand { protected loginStrategyService: LoginStrategyServiceAbstraction, protected authService: AuthService, protected apiService: ApiService, + protected masterPasswordApiService: MasterPasswordApiService, protected cryptoFunctionService: CryptoFunctionService, protected environmentService: EnvironmentService, protected passwordGenerationService: PasswordGenerationServiceAbstraction, @@ -321,7 +323,7 @@ export class LoginCommand { })({ type: "input", name: "token", - message: "New device login code:", + message: "New device verification required. Enter OTP sent to login email:", }); newDeviceToken = answer.token; } @@ -454,7 +456,7 @@ export class LoginCommand { request.newMasterPasswordHash = newPasswordHash; request.key = newUserKey[1].encryptedString; - await this.apiService.postPassword(request); + await this.masterPasswordApiService.postPassword(request); return await this.handleUpdatePasswordSuccessResponse(); } catch (e) { @@ -491,7 +493,7 @@ export class LoginCommand { request.newMasterPasswordHash = newPasswordHash; request.masterPasswordHint = hint; - await this.apiService.putUpdateTempPassword(request); + await this.masterPasswordApiService.putUpdateTempPassword(request); return await this.handleUpdatePasswordSuccessResponse(); } catch (e) { diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index a118985bf0d..c6b79c7dff2 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -155,6 +155,7 @@ export class Program extends BaseProgram { this.serviceContainer.loginStrategyService, this.serviceContainer.authService, this.serviceContainer.apiService, + this.serviceContainer.masterPasswordApiService, this.serviceContainer.cryptoFunctionService, this.serviceContainer.environmentService, this.serviceContainer.passwordGenerationService, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 3825e6777c8..93d269ce095 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -36,6 +36,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; +import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { AccountServiceImplementation, @@ -46,6 +47,7 @@ import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; import { DeviceTrustService } from "@bitwarden/common/auth/services/device-trust.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; +import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation"; import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; @@ -280,6 +282,7 @@ export class ServiceContainer { sdkLoadService: SdkLoadService; cipherAuthorizationService: CipherAuthorizationService; ssoUrlService: SsoUrlService; + masterPasswordApiService: MasterPasswordApiServiceAbstraction; constructor() { let p = null; @@ -779,6 +782,7 @@ export class ServiceContainer { this.encryptService, this.pinService, this.accountService, + this.sdkService, ); this.individualExportService = new IndividualVaultExportService( @@ -841,6 +845,8 @@ export class ServiceContainer { this.organizationService, this.accountService, ); + + this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService); } async logout() { diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 5478d397651..5d4c547b750 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -9,11 +9,55 @@ edition = "2021" publish = false [workspace.dependencies] +aes = "=0.8.4" anyhow = "=1.0.94" +arboard = { version = "=3.4.1", default-features = false } +argon2 = "=0.5.3" +base64 = "=0.22.1" +bindgen = "0.71.1" +bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "3d48f140fd506412d186203238993163a8c4e536" } +byteorder = "=1.5.0" +bytes = "1.9.0" +cbc = "=0.1.2" +core-foundation = "=0.10.0" +dirs = "=6.0.0" +ed25519 = "=2.2.3" +embed_plist = "=1.2.2" +futures = "=0.3.31" +hex = "=0.4.3" +homedir = "=0.3.4" +interprocess = "=2.2.1" +keytar = "=0.1.6" +libc = "=0.2.169" log = "=0.4.25" +napi = "=2.16.15" +napi-build = "=2.1.4" +napi-derive = "=2.16.13" +oo7 = "=0.3.3" +oslog = "=0.2.0" +pin-project = "=1.1.8" +pkcs8 = "=0.10.2" +rand = "=0.8.5" +rsa = "=0.9.6" +russh-cryptovec = "=0.7.3" +scopeguard = "=1.2.0" +security-framework = "=3.1.0" +security-framework-sys = "=2.13.0" serde = "=1.0.209" serde_json = "=1.0.127" -tokio = "=1.43.0" -tokio-util = "=0.7.13" -tokio-stream = "=0.1.15" +sha2 = "=0.10.8" +simplelog = "=0.12.2" +ssh-encoding = "=0.2.0" +ssh-key = {version = "=0.6.7", default-features = false } +sysinfo = "0.33.1" thiserror = "=1.0.69" +tokio = "=1.43.0" +tokio-stream = "=0.1.15" +tokio-util = "=0.7.13" +typenum = "=1.17.0" +uniffi = "=0.28.3" +widestring = "=1.1.0" +windows = "=0.58.0" +windows-registry = "=0.4.0" +zbus = "=4.4.0" +zbus_polkit = "=4.0.0" diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index 8a0bcd0e0a7..346f3bde96b 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -18,47 +18,47 @@ default = [ manual_test = [] [dependencies] -aes = "=0.8.4" +aes = { workspace = true } anyhow = { workspace = true } -arboard = { version = "=3.4.1", default-features = false, features = [ +arboard = { workspace = true, features = [ "wayland-data-control", ] } -argon2 = { version = "=0.5.3", features = ["zeroize"] } -base64 = "=0.22.1" -byteorder = "=1.5.0" -cbc = { version = "=0.1.2", features = ["alloc"] } -homedir = "=0.3.4" -pin-project = "=1.1.8" -dirs = "=6.0.0" -futures = "=0.3.31" -interprocess = { version = "=2.2.1", features = ["tokio"] } +argon2 = { workspace = true, features = ["zeroize"] } +base64 = { workspace = true } +byteorder = { workspace = true } +cbc = { workspace = true, features = ["alloc"] } +homedir = { workspace = true } +pin-project = { workspace = true } +dirs = { workspace = true } +futures = { workspace = true } +interprocess = { workspace = true, features = ["tokio"] } log = { workspace = true } -rand = "=0.8.5" -russh-cryptovec = "=0.7.3" -scopeguard = "=1.2.0" -sha2 = "=0.10.8" -ssh-encoding = "=0.2.0" -ssh-key = { version = "=0.6.7", default-features = false, features = [ +rand = { workspace = true } +russh-cryptovec = { workspace = true } +scopeguard = { workspace = true } +sha2 = { workspace = true } +ssh-encoding = { workspace = true } +ssh-key = { workspace = true, features = [ "encryption", "ed25519", "rsa", "getrandom", ] } -bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "3d48f140fd506412d186203238993163a8c4e536" } +bitwarden-russh = { workspace = true } tokio = { workspace = true, features = ["io-util", "sync", "macros", "net"] } tokio-stream = { workspace = true, features = ["net"] } tokio-util = { workspace = true, features = ["codec"] } thiserror = { workspace = true } -typenum = "=1.17.0" -pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] } -rsa = "=0.9.6" -ed25519 = { version = "=2.2.3", features = ["pkcs8"] } -bytes = "1.9.0" -sysinfo = { version = "0.33.1", features = ["windows"] } +typenum = { workspace = true } +pkcs8 = { workspace = true, features = ["alloc", "encryption", "pem"] } +rsa = { workspace = true } +ed25519 = { workspace = true, features = ["pkcs8"] } +bytes = { workspace = true } +sysinfo = { workspace = true, features = ["windows"] } [target.'cfg(windows)'.dependencies] -widestring = { version = "=1.1.0", optional = true } -windows = { version = "=0.58.0", features = [ +widestring = { workspace = true, optional = true } +windows = { workspace = true, features = [ "Foundation", "Security_Credentials_UI", "Security_Cryptography", @@ -72,17 +72,17 @@ windows = { version = "=0.58.0", features = [ ], optional = true } [target.'cfg(windows)'.dev-dependencies] -keytar = "=0.1.6" +keytar = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] -core-foundation = { version = "=0.10.0", optional = true } -security-framework = { version = "=3.1.0", optional = true } -security-framework-sys = { version = "=2.13.0", optional = true } +core-foundation = { workspace = true, optional = true } +security-framework = { workspace = true, optional = true } +security-framework-sys = { workspace = true, optional = true } desktop_objc = { path = "../objc" } [target.'cfg(target_os = "linux")'.dependencies] -oo7 = "=0.3.3" -libc = "=0.2.169" +oo7 = { workspace = true } +libc = { workspace = true } -zbus = { version = "=4.4.0", optional = true } -zbus_polkit = { version = "=4.0.0", optional = true } +zbus = { workspace = true, optional = true } +zbus_polkit = { workspace = true, optional = true } diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs b/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs deleted file mode 100644 index 52464487ec5..00000000000 --- a/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs +++ /dev/null @@ -1,402 +0,0 @@ -use ed25519; -use pkcs8::{ - der::Decode, EncryptedPrivateKeyInfo, ObjectIdentifier, PrivateKeyInfo, SecretDocument, -}; -use ssh_key::{ - private::{Ed25519Keypair, Ed25519PrivateKey, RsaKeypair}, - HashAlg, LineEnding, -}; - -const PKCS1_HEADER: &str = "-----BEGIN RSA PRIVATE KEY-----"; -const PKCS8_UNENCRYPTED_HEADER: &str = "-----BEGIN PRIVATE KEY-----"; -const PKCS8_ENCRYPTED_HEADER: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----"; -const OPENSSH_HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----"; - -pub const RSA_PKCS8_ALGORITHM_OID: ObjectIdentifier = - ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"); - -#[derive(Debug)] -enum KeyType { - Ed25519, - Rsa, - Unknown, -} - -pub fn import_key( - encoded_key: String, - password: String, -) -> Result { - match encoded_key.lines().next() { - Some(PKCS1_HEADER) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::UnsupportedKeyType, - ssh_key: None, - }), - Some(PKCS8_UNENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, None) { - Ok(result) => Ok(result), - Err(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - }, - Some(PKCS8_ENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, Some(password)) { - Ok(result) => Ok(result), - Err(err) => match err { - SshKeyImportError::PasswordRequired => Ok(SshKeyImportResult { - status: SshKeyImportStatus::PasswordRequired, - ssh_key: None, - }), - SshKeyImportError::WrongPassword => Ok(SshKeyImportResult { - status: SshKeyImportStatus::WrongPassword, - ssh_key: None, - }), - SshKeyImportError::ParsingError => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - }, - }, - Some(OPENSSH_HEADER) => import_openssh_key(encoded_key, password), - Some(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - None => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - } -} - -fn import_pkcs8_key( - encoded_key: String, - password: Option, -) -> Result { - let der = match SecretDocument::from_pem(&encoded_key) { - Ok((_, doc)) => doc, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }); - } - }; - - let decrypted_der = match password.clone() { - Some(password) => { - let encrypted_private_key_info = match EncryptedPrivateKeyInfo::from_der(der.as_bytes()) - { - Ok(info) => info, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }); - } - }; - match encrypted_private_key_info.decrypt(password.as_bytes()) { - Ok(der) => der, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::WrongPassword, - ssh_key: None, - }); - } - } - } - None => der, - }; - - let key_type: KeyType = match PrivateKeyInfo::from_der(decrypted_der.as_bytes()) - .map_err(|_| SshKeyImportError::ParsingError)? - .algorithm - .oid - { - ed25519::pkcs8::ALGORITHM_OID => KeyType::Ed25519, - RSA_PKCS8_ALGORITHM_OID => KeyType::Rsa, - _ => KeyType::Unknown, - }; - - match key_type { - KeyType::Ed25519 => { - let pk: ed25519::KeypairBytes = match password { - Some(password) => { - pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password) - .map_err(|err| match err { - ed25519::pkcs8::Error::EncryptedPrivateKey(_) => { - SshKeyImportError::WrongPassword - } - _ => SshKeyImportError::ParsingError, - })? - } - None => ed25519::pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key) - .map_err(|_| SshKeyImportError::ParsingError)?, - }; - let pk: Ed25519Keypair = - Ed25519Keypair::from(Ed25519PrivateKey::from_bytes(&pk.secret_key)); - let private_key = ssh_key::private::PrivateKey::from(pk); - Ok(SshKeyImportResult { - status: SshKeyImportStatus::Success, - ssh_key: Some(SshKey { - private_key: private_key.to_openssh(LineEnding::LF).unwrap().to_string(), - public_key: private_key.public_key().to_string(), - key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), - }), - }) - } - KeyType::Rsa => { - let pk: rsa::RsaPrivateKey = match password { - Some(password) => { - pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password) - .map_err(|err| match err { - pkcs8::Error::EncryptedPrivateKey(_) => { - SshKeyImportError::WrongPassword - } - _ => SshKeyImportError::ParsingError, - })? - } - None => pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key) - .map_err(|_| SshKeyImportError::ParsingError)?, - }; - let rsa_keypair: Result = RsaKeypair::try_from(pk); - match rsa_keypair { - Ok(rsa_keypair) => { - let private_key = ssh_key::private::PrivateKey::from(rsa_keypair); - Ok(SshKeyImportResult { - status: SshKeyImportStatus::Success, - ssh_key: Some(SshKey { - private_key: private_key - .to_openssh(LineEnding::LF) - .unwrap() - .to_string(), - public_key: private_key.public_key().to_string(), - key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), - }), - }) - } - Err(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - } - } - _ => Ok(SshKeyImportResult { - status: SshKeyImportStatus::UnsupportedKeyType, - ssh_key: None, - }), - } -} - -fn import_openssh_key( - encoded_key: String, - password: String, -) -> Result { - let private_key = ssh_key::private::PrivateKey::from_openssh(&encoded_key); - let private_key = match private_key { - Ok(k) => k, - Err(err) => { - match err { - ssh_key::Error::AlgorithmUnknown - | ssh_key::Error::AlgorithmUnsupported { algorithm: _ } => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::UnsupportedKeyType, - ssh_key: None, - }); - } - _ => {} - } - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }); - } - }; - - if private_key.is_encrypted() && password.is_empty() { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::PasswordRequired, - ssh_key: None, - }); - } - let private_key = if private_key.is_encrypted() { - match private_key.decrypt(password.as_bytes()) { - Ok(k) => k, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::WrongPassword, - ssh_key: None, - }); - } - } - } else { - private_key - }; - - match private_key.to_openssh(LineEnding::LF) { - Ok(private_key_openssh) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::Success, - ssh_key: Some(SshKey { - private_key: private_key_openssh.to_string(), - public_key: private_key.public_key().to_string(), - key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), - }), - }), - Err(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - } -} - -#[derive(PartialEq, Debug)] -pub enum SshKeyImportStatus { - /// ssh key was parsed correctly and will be returned in the result - Success, - /// ssh key was parsed correctly but is encrypted and requires a password - PasswordRequired, - /// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect - WrongPassword, - /// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key - ParsingError, - /// ssh key type is not supported - UnsupportedKeyType, -} - -pub enum SshKeyImportError { - ParsingError, - PasswordRequired, - WrongPassword, -} - -pub struct SshKeyImportResult { - pub status: SshKeyImportStatus, - pub ssh_key: Option, -} - -pub struct SshKey { - pub private_key: String, - pub public_key: String, - pub key_fingerprint: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn import_key_ed25519_openssh_unencrypted() { - let private_key = include_str!("./test_keys/ed25519_openssh_unencrypted"); - let public_key = include_str!("./test_keys/ed25519_openssh_unencrypted.pub").trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_ed25519_openssh_encrypted() { - let private_key = include_str!("./test_keys/ed25519_openssh_encrypted"); - let public_key = include_str!("./test_keys/ed25519_openssh_encrypted.pub").trim(); - let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_openssh_unencrypted() { - let private_key = include_str!("./test_keys/rsa_openssh_unencrypted"); - let public_key = include_str!("./test_keys/rsa_openssh_unencrypted.pub").trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_openssh_encrypted() { - let private_key = include_str!("./test_keys/rsa_openssh_encrypted"); - let public_key = include_str!("./test_keys/rsa_openssh_encrypted.pub").trim(); - let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_ed25519_pkcs8_unencrypted() { - let private_key = include_str!("./test_keys/ed25519_pkcs8_unencrypted"); - let public_key = - include_str!("./test_keys/ed25519_pkcs8_unencrypted.pub").replace("testkey", ""); - let public_key = public_key.trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_pkcs8_unencrypted() { - let private_key = include_str!("./test_keys/rsa_pkcs8_unencrypted"); - // for whatever reason pkcs8 + rsa does not include the comment in the public key - let public_key = - include_str!("./test_keys/rsa_pkcs8_unencrypted.pub").replace("testkey", ""); - let public_key = public_key.trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_pkcs8_encrypted() { - let private_key = include_str!("./test_keys/rsa_pkcs8_encrypted"); - let public_key = include_str!("./test_keys/rsa_pkcs8_encrypted.pub").replace("testkey", ""); - let public_key = public_key.trim(); - let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_ed25519_openssh_encrypted_wrong_password() { - let private_key = include_str!("./test_keys/ed25519_openssh_encrypted"); - let result = import_key(private_key.to_string(), "wrongpassword".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::WrongPassword); - } - - #[test] - fn import_non_key_error() { - let result = import_key("not a key".to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::ParsingError); - } - - #[test] - fn import_ecdsa_error() { - let private_key = include_str!("./test_keys/ecdsa_openssh_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType); - } - - // Putty-exported keys should be supported, but are not due to a parser incompatibility. - // Should this test start failing, please change it to expect a correct key, and - // make sure the documentation support for putty-exported keys this is updated. - // https://bitwarden.atlassian.net/browse/PM-14989 - #[test] - fn import_key_ed25519_putty() { - let private_key = include_str!("./test_keys/ed25519_putty_openssh_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::ParsingError); - } - - // Putty-exported keys should be supported, but are not due to a parser incompatibility. - // Should this test start failing, please change it to expect a correct key, and - // make sure the documentation support for putty-exported keys this is updated. - // https://bitwarden.atlassian.net/browse/PM-14989 - #[test] - fn import_key_rsa_openssh_putty() { - let private_key = include_str!("./test_keys/rsa_putty_openssh_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::ParsingError); - } - - #[test] - fn import_key_rsa_pkcs8_putty() { - let private_key = include_str!("./test_keys/rsa_putty_pkcs1_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType); - } -} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 3fe327948f8..5f794b49c73 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -16,7 +16,6 @@ mod platform_ssh_agent; #[cfg(any(target_os = "linux", target_os = "macos"))] mod peercred_unix_listener_stream; -pub mod importer; pub mod peerinfo; mod request_parser; diff --git a/apps/desktop/desktop_native/macos_provider/Cargo.toml b/apps/desktop/desktop_native/macos_provider/Cargo.toml index e160f7f35d6..cf7338f23cf 100644 --- a/apps/desktop/desktop_native/macos_provider/Cargo.toml +++ b/apps/desktop/desktop_native/macos_provider/Cargo.toml @@ -15,16 +15,16 @@ bench = false [dependencies] desktop_core = { path = "../core" } -futures = "=0.3.31" +futures = { workspace = true } log = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["sync"] } tokio-util = { workspace = true } -uniffi = { version = "=0.28.3", features = ["cli"] } +uniffi = { workspace = true, features = ["cli"] } [target.'cfg(target_os = "macos")'.dependencies] -oslog = "=0.2.0" +oslog = { workspace = true } [build-dependencies] -uniffi = { version = "=0.28.3", features = ["build"] } +uniffi = { workspace = true, features = ["build"] } diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index a9513fa49a8..f5b6d6b7c04 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -14,12 +14,12 @@ default = [] manual_test = [] [dependencies] -base64 = "=0.22.1" -hex = "=0.4.3" +base64 = { workspace = true } +hex = { workspace = true } anyhow = { workspace = true } desktop_core = { path = "../core" } -napi = { version = "=2.16.15", features = ["async"] } -napi-derive = "=2.16.13" +napi = { workspace = true, features = ["async"] } +napi-derive = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true } @@ -27,7 +27,7 @@ tokio-util = { workspace = true } tokio-stream = { workspace = true } [target.'cfg(windows)'.dependencies] -windows-registry = "=0.4.0" +windows-registry = { workspace = true } [build-dependencies] -napi-build = "=2.1.4" +napi-build = { workspace = true } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index c40b7aed487..92f31cf5f89 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -51,22 +51,6 @@ export declare namespace sshagent { publicKey: string keyFingerprint: string } - export const enum SshKeyImportStatus { - /** ssh key was parsed correctly and will be returned in the result */ - Success = 0, - /** ssh key was parsed correctly but is encrypted and requires a password */ - PasswordRequired = 1, - /** ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect */ - WrongPassword = 2, - /** ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key */ - ParsingError = 3, - /** ssh key type is not supported (e.g. ecdsa) */ - UnsupportedKeyType = 4 - } - export interface SshKeyImportResult { - status: SshKeyImportStatus - sshKey?: SshKey - } export interface SshUiRequest { cipherId?: string isList: boolean @@ -79,7 +63,6 @@ export declare namespace sshagent { export function isRunning(agentState: SshAgentState): boolean export function setKeys(agentState: SshAgentState, newKeys: Array): void export function lock(agentState: SshAgentState): void - export function importKey(encodedKey: string, password: string): SshKeyImportResult export function clearKeys(agentState: SshAgentState): void export class SshAgentState { } } diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 7d20bd50699..d0c859d427c 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -182,67 +182,6 @@ pub mod sshagent { pub key_fingerprint: String, } - impl From for SshKey { - fn from(key: desktop_core::ssh_agent::importer::SshKey) -> Self { - SshKey { - private_key: key.private_key, - public_key: key.public_key, - key_fingerprint: key.key_fingerprint, - } - } - } - - #[napi] - pub enum SshKeyImportStatus { - /// ssh key was parsed correctly and will be returned in the result - Success, - /// ssh key was parsed correctly but is encrypted and requires a password - PasswordRequired, - /// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect - WrongPassword, - /// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key - ParsingError, - /// ssh key type is not supported (e.g. ecdsa) - UnsupportedKeyType, - } - - impl From for SshKeyImportStatus { - fn from(status: desktop_core::ssh_agent::importer::SshKeyImportStatus) -> Self { - match status { - desktop_core::ssh_agent::importer::SshKeyImportStatus::Success => { - SshKeyImportStatus::Success - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::PasswordRequired => { - SshKeyImportStatus::PasswordRequired - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::WrongPassword => { - SshKeyImportStatus::WrongPassword - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::ParsingError => { - SshKeyImportStatus::ParsingError - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::UnsupportedKeyType => { - SshKeyImportStatus::UnsupportedKeyType - } - } - } - } - - #[napi(object)] - pub struct SshKeyImportResult { - pub status: SshKeyImportStatus, - pub ssh_key: Option, - } - - impl From for SshKeyImportResult { - fn from(result: desktop_core::ssh_agent::importer::SshKeyImportResult) -> Self { - SshKeyImportResult { - status: result.status.into(), - ssh_key: result.ssh_key.map(|k| k.into()), - } - } - } - #[napi(object)] pub struct SshUIRequest { pub cipher_id: Option, @@ -359,13 +298,6 @@ pub mod sshagent { .map_err(|e| napi::Error::from_reason(e.to_string())) } - #[napi] - pub fn import_key(encoded_key: String, password: String) -> napi::Result { - let result = desktop_core::ssh_agent::importer::import_key(encoded_key, password) - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - Ok(result.into()) - } - #[napi] pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> { let bitwarden_agent_state = &mut agent_state.state; diff --git a/apps/desktop/desktop_native/proxy/Cargo.toml b/apps/desktop/desktop_native/proxy/Cargo.toml index 10ff1ed8eae..a9a1a5aca28 100644 --- a/apps/desktop/desktop_native/proxy/Cargo.toml +++ b/apps/desktop/desktop_native/proxy/Cargo.toml @@ -8,11 +8,11 @@ publish = { workspace = true } [dependencies] anyhow = { workspace = true } desktop_core = { path = "../core" } -futures = "=0.3.31" +futures = { workspace = true } log = { workspace = true } -simplelog = "=0.12.2" +simplelog = { workspace = true } tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt"] } tokio-util = { workspace = true, features = ["codec"] } [target.'cfg(target_os = "macos")'.dependencies] -embed_plist = "=1.2.2" +embed_plist = { workspace = true } diff --git a/apps/desktop/desktop_native/windows-plugin-authenticator/Cargo.toml b/apps/desktop/desktop_native/windows-plugin-authenticator/Cargo.toml index 3443fed4fd6..d58a6ecd748 100644 --- a/apps/desktop/desktop_native/windows-plugin-authenticator/Cargo.toml +++ b/apps/desktop/desktop_native/windows-plugin-authenticator/Cargo.toml @@ -6,4 +6,5 @@ version = { workspace = true } publish = { workspace = true } [target.'cfg(target_os = "windows")'.build-dependencies] -bindgen = "0.71.1" +bindgen = { workspace = true } + diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index e76526a6618..bcbd9969f96 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -423,7 +423,7 @@ "enableHardwareAccelerationDesc" | i18n }}
-
+
- -
- - Bitwarden - -

{{ "loginOrCreateNewAccount" | i18n }}

- - -
-
-
- - -
-
- - -
-
- -
-
-
- -
-
-
-

{{ "newAroundHere" | i18n }}

- -
-
- - -
-
-
-
- - -
-
- -
-
-
-
-
-
- -
- -
-
-
-
-
- -
-
- -
-
- -
-
-
- -
-

{{ "loggingInAs" | i18n }} {{ loggedEmail }}

- {{ "notYou" | i18n }} -
-
-
-
- -
- diff --git a/apps/desktop/src/auth/login/login-v1.component.ts b/apps/desktop/src/auth/login/login-v1.component.ts deleted file mode 100644 index ff8688353be..00000000000 --- a/apps/desktop/src/auth/login/login-v1.component.ts +++ /dev/null @@ -1,266 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, NgZone, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, firstValueFrom, takeUntil, tap } from "rxjs"; - -import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component"; -import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, -} from "@bitwarden/auth/common"; -import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { ToastService } from "@bitwarden/components"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; - -import { EnvironmentComponent } from "../environment.component"; - -const BroadcasterSubscriptionId = "LoginComponent"; - -@Component({ - selector: "app-login", - templateUrl: "login-v1.component.html", -}) -export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDestroy { - @ViewChild("environment", { read: ViewContainerRef, static: true }) - environmentModal: ViewContainerRef; - - protected componentDestroyed$: Subject = new Subject(); - webVaultHostname = ""; - - showingModal = false; - - private deferFocus: boolean = null; - - get loggedEmail() { - return this.formGroup.value.email; - } - - constructor( - devicesApiService: DevicesApiServiceAbstraction, - appIdService: AppIdService, - loginStrategyService: LoginStrategyServiceAbstraction, - router: Router, - i18nService: I18nService, - syncService: SyncService, - private modalService: ModalService, - platformUtilsService: PlatformUtilsService, - stateService: StateService, - environmentService: EnvironmentService, - passwordGenerationService: PasswordGenerationServiceAbstraction, - cryptoFunctionService: CryptoFunctionService, - private broadcasterService: BroadcasterService, - ngZone: NgZone, - private messagingService: MessagingService, - logService: LogService, - formBuilder: FormBuilder, - formValidationErrorService: FormValidationErrorsService, - route: ActivatedRoute, - loginEmailService: LoginEmailServiceAbstraction, - ssoLoginService: SsoLoginServiceAbstraction, - toastService: ToastService, - private configService: ConfigService, - ) { - super( - devicesApiService, - appIdService, - loginStrategyService, - router, - platformUtilsService, - i18nService, - stateService, - environmentService, - passwordGenerationService, - cryptoFunctionService, - logService, - ngZone, - formBuilder, - formValidationErrorService, - route, - loginEmailService, - ssoLoginService, - toastService, - ); - this.onSuccessfulLogin = () => { - return syncService.fullSync(true); - }; - } - - async ngOnInit() { - this.listenForUnauthUiRefreshFlagChanges(); - - await super.ngOnInit(); - await this.getLoginWithDevice(this.loggedEmail); - this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { - this.ngZone.run(() => { - switch (message.command) { - case "windowHidden": - this.onWindowHidden(); - break; - case "windowIsFocused": - if (this.deferFocus === null) { - this.deferFocus = !message.windowIsFocused; - if (!this.deferFocus) { - this.focusInput(); - } - } else if (this.deferFocus && message.windowIsFocused) { - this.focusInput(); - this.deferFocus = false; - } - break; - default: - } - }); - }); - this.messagingService.send("getWindowIsFocused"); - } - - ngOnDestroy() { - this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); - this.componentDestroyed$.next(); - this.componentDestroyed$.complete(); - } - - private listenForUnauthUiRefreshFlagChanges() { - this.configService - .getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh) - .pipe( - tap(async (flag) => { - if (flag) { - const qParams = await firstValueFrom(this.route.queryParams); - - const uniqueQueryParams = { - ...qParams, - // adding a unique timestamp to the query params to force a reload - t: new Date().getTime().toString(), - }; - - await this.router.navigate(["/"], { - queryParams: uniqueQueryParams, - }); - } - }), - takeUntil(this.componentDestroyed$), - ) - .subscribe(); - } - - async settings() { - const [modal, childComponent] = await this.modalService.openViewRef( - EnvironmentComponent, - this.environmentModal, - ); - - modal.onShown.pipe(takeUntil(this.componentDestroyed$)).subscribe(() => { - this.showingModal = true; - }); - - modal.onClosed.pipe(takeUntil(this.componentDestroyed$)).subscribe(() => { - this.showingModal = false; - }); - - // eslint-disable-next-line rxjs/no-async-subscribe - childComponent.onSaved.pipe(takeUntil(this.componentDestroyed$)).subscribe(async () => { - modal.close(); - await this.getLoginWithDevice(this.loggedEmail); - }); - } - - onWindowHidden() { - this.showPassword = false; - } - - async continue() { - await super.validateEmail(); - if (!this.formGroup.controls.email.valid) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccured"), - message: this.i18nService.t("invalidEmail"), - }); - return; - } - this.focusInput(); - } - - async submit() { - if (!this.validatedEmail) { - return; - } - - await super.submit(); - if (this.captchaSiteKey) { - const content = document.getElementById("content") as HTMLDivElement; - content.setAttribute("style", "width:335px"); - } - } - - private focusInput() { - const email = this.loggedEmail; - document.getElementById(email == null || email === "" ? "email" : "masterPassword")?.focus(); - } - - async launchSsoBrowser(clientId: string, ssoRedirectUri: string) { - if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) { - return super.launchSsoBrowser(clientId, ssoRedirectUri); - } - const email = this.formGroup.controls.email.value; - - // Save off email for SSO - await this.ssoLoginService.setSsoEmail(email); - - // Generate necessary sso params - const passwordOptions: any = { - type: "password", - length: 64, - uppercase: true, - lowercase: true, - numbers: true, - special: false, - }; - const state = await this.passwordGenerationService.generatePassword(passwordOptions); - const ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); - const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256"); - const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); - - // Save sso params - await this.ssoLoginService.setSsoState(state); - await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier); - - try { - await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state, email); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (err) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccured"), - this.i18nService.t("ssoError"), - ); - } - } - - /** - * Force the validatedEmail flag to false, which will show the login page. - */ - invalidateEmail() { - this.validatedEmail = false; - } -} diff --git a/apps/desktop/src/auth/login/login-via-auth-request-v1.component.html b/apps/desktop/src/auth/login/login-via-auth-request-v1.component.html deleted file mode 100644 index 9825949f7ec..00000000000 --- a/apps/desktop/src/auth/login/login-via-auth-request-v1.component.html +++ /dev/null @@ -1,80 +0,0 @@ -
-
- Bitwarden - - -

{{ "logInRequestSent" | i18n }}

- -
-
-
-
-

- {{ "notificationSentDevicePart1" | i18n }} - {{ "notificationSentDeviceAnchor" | i18n }}. {{ "notificationSentDevicePart2" | i18n }} -

-
- -
-

{{ "fingerprintPhraseHeader" | i18n }}

- {{ fingerprintPhrase }} -
- - - -
-

- {{ "needAnotherOption" | i18n }} - - {{ "viewAllLoginOptions" | i18n }} - -

-
-
-
-
-
- - -

{{ "adminApprovalRequested" | i18n }}

- -
-
-
-
-

{{ "adminApprovalRequestSentToAdmins" | i18n }}

-

{{ "youWillBeNotifiedOnceApproved" | i18n }}

-
- -
-

{{ "fingerprintPhraseHeader" | i18n }}

- {{ fingerprintPhrase }} -
- -
-

- {{ "troubleLoggingIn" | i18n }} - - {{ "viewAllLoginOptions" | i18n }} - -

-
-
-
-
-
-
-
- diff --git a/apps/desktop/src/auth/login/login-via-auth-request-v1.component.ts b/apps/desktop/src/auth/login/login-via-auth-request-v1.component.ts deleted file mode 100644 index 30e693b9ac6..00000000000 --- a/apps/desktop/src/auth/login/login-via-auth-request-v1.component.ts +++ /dev/null @@ -1,117 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Location } from "@angular/common"; -import { Component, ViewChild, ViewContainerRef } from "@angular/core"; -import { Router } from "@angular/router"; - -import { LoginViaAuthRequestComponentV1 as BaseLoginViaAuthRequestComponentV1 } from "@bitwarden/angular/auth/components/login-via-auth-request-v1.component"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { - AuthRequestServiceAbstraction, - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, -} from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; -import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { ToastService } from "@bitwarden/components"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { KeyService } from "@bitwarden/key-management"; - -import { EnvironmentComponent } from "../environment.component"; - -@Component({ - selector: "app-login-via-auth-request", - templateUrl: "login-via-auth-request-v1.component.html", -}) -export class LoginViaAuthRequestComponentV1 extends BaseLoginViaAuthRequestComponentV1 { - @ViewChild("environment", { read: ViewContainerRef, static: true }) - environmentModal: ViewContainerRef; - showingModal = false; - - constructor( - protected router: Router, - keyService: KeyService, - cryptoFunctionService: CryptoFunctionService, - appIdService: AppIdService, - passwordGenerationService: PasswordGenerationServiceAbstraction, - apiService: ApiService, - authService: AuthService, - logService: LogService, - environmentService: EnvironmentService, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - anonymousHubService: AnonymousHubService, - validationService: ValidationService, - private modalService: ModalService, - syncService: SyncService, - loginEmailService: LoginEmailServiceAbstraction, - deviceTrustService: DeviceTrustServiceAbstraction, - authRequestService: AuthRequestServiceAbstraction, - loginStrategyService: LoginStrategyServiceAbstraction, - accountService: AccountService, - private location: Location, - toastService: ToastService, - ) { - super( - router, - keyService, - cryptoFunctionService, - appIdService, - passwordGenerationService, - apiService, - authService, - logService, - environmentService, - i18nService, - platformUtilsService, - anonymousHubService, - validationService, - accountService, - loginEmailService, - deviceTrustService, - authRequestService, - loginStrategyService, - toastService, - ); - - this.onSuccessfulLogin = () => { - return syncService.fullSync(true); - }; - } - - async settings() { - const [modal, childComponent] = await this.modalService.openViewRef( - EnvironmentComponent, - this.environmentModal, - ); - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - modal.onShown.subscribe(() => { - this.showingModal = true; - }); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - modal.onClosed.subscribe(() => { - this.showingModal = false; - }); - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - childComponent.onSaved.subscribe(() => { - modal.close(); - }); - } - - back() { - this.location.back(); - } -} diff --git a/apps/desktop/src/auth/login/login.module.ts b/apps/desktop/src/auth/login/login.module.ts index 427cbcb2069..6fe9f9b07d3 100644 --- a/apps/desktop/src/auth/login/login.module.ts +++ b/apps/desktop/src/auth/login/login.module.ts @@ -6,17 +6,10 @@ import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components import { SharedModule } from "../../app/shared/shared.module"; import { LoginDecryptionOptionsComponentV1 } from "./login-decryption-options/login-decryption-options-v1.component"; -import { LoginComponentV1 } from "./login-v1.component"; -import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component"; @NgModule({ imports: [SharedModule, RouterModule], - declarations: [ - LoginComponentV1, - LoginViaAuthRequestComponentV1, - EnvironmentSelectorComponent, - LoginDecryptionOptionsComponentV1, - ], - exports: [LoginComponentV1, LoginViaAuthRequestComponentV1], + declarations: [EnvironmentSelectorComponent, LoginDecryptionOptionsComponentV1], + exports: [], }) export class LoginModule {} diff --git a/apps/desktop/src/auth/set-password.component.ts b/apps/desktop/src/auth/set-password.component.ts index ed4e8de5b3d..a41c87e1cdb 100644 --- a/apps/desktop/src/auth/set-password.component.ts +++ b/apps/desktop/src/auth/set-password.component.ts @@ -9,6 +9,7 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -40,6 +41,7 @@ export class SetPasswordComponent extends BaseSetPasswordComponent implements On policyApiService: PolicyApiServiceAbstraction, policyService: PolicyService, router: Router, + masterPasswordApiService: MasterPasswordApiService, syncService: SyncService, route: ActivatedRoute, private broadcasterService: BroadcasterService, @@ -63,6 +65,7 @@ export class SetPasswordComponent extends BaseSetPasswordComponent implements On policyApiService, policyService, router, + masterPasswordApiService, apiService, syncService, route, diff --git a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts index af79d9d7316..595ef778bcf 100644 --- a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts +++ b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts @@ -25,16 +25,6 @@ export class MainSshAgentService { private logService: LogService, private messagingService: MessagingService, ) { - ipcMain.handle( - "sshagent.importkey", - async ( - event: any, - { privateKey, password }: { privateKey: string; password?: string }, - ): Promise => { - return sshagent.importKey(privateKey, password); - }, - ); - ipcMain.handle("sshagent.init", async (event: any, message: any) => { this.init(); }); diff --git a/apps/desktop/src/autofill/services/ssh-agent.service.ts b/apps/desktop/src/autofill/services/ssh-agent.service.ts index 87c6de75a95..bf7167c0240 100644 --- a/apps/desktop/src/autofill/services/ssh-agent.service.ts +++ b/apps/desktop/src/autofill/services/ssh-agent.service.ts @@ -24,8 +24,6 @@ import { import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging"; @@ -58,23 +56,13 @@ export class SshAgentService implements OnDestroy { private toastService: ToastService, private i18nService: I18nService, private desktopSettingsService: DesktopSettingsService, - private configService: ConfigService, private accountService: AccountService, ) {} async init() { - this.configService - .getFeatureFlag$(FeatureFlag.SSHAgent) - .pipe( - concatMap(async (enabled) => { - this.isFeatureFlagEnabled = enabled; - if (!(await ipc.platform.sshAgent.isLoaded()) && enabled) { - await ipc.platform.sshAgent.init(); - } - }), - takeUntil(this.destroy$), - ) - .subscribe(); + if (!(await ipc.platform.sshAgent.isLoaded())) { + await ipc.platform.sshAgent.init(); + } await this.initListeners(); } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index b485b471ccb..0bc76287adb 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3060,9 +3060,6 @@ "adminApprovalRequestSentToAdmins": { "message": "Your request has been sent to your admin." }, - "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." - }, "troubleLoggingIn": { "message": "Trouble logging in?" }, @@ -3532,9 +3529,6 @@ "unknownApplication": { "message": "An application" }, - "sshKeyPasswordUnsupported": { - "message": "Importing password protected SSH keys is not yet supported" - }, "invalidSshKey": { "message": "The SSH key is invalid" }, @@ -3544,7 +3538,7 @@ "importSshKeyFromClipboard": { "message": "Import key from clipboard" }, - "sshKeyPasted": { + "sshKeyImported": { "message": "SSH key imported successfully" }, "fileSavedToDevice": { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4e167f30ec8..e4894b159fe 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -284,6 +284,8 @@ export class Main { this.migrationRunner.run().then( async () => { await this.toggleHardwareAcceleration(); + // Reset modal mode to make sure main window is displayed correctly + await this.desktopSettingsService.resetInModalMode(); await this.windowMain.init(); await this.i18nService.init(); await this.messagingMain.init(); diff --git a/apps/desktop/src/main/tray.main.ts b/apps/desktop/src/main/tray.main.ts index 9fa7fe6143f..e63e2a00c85 100644 --- a/apps/desktop/src/main/tray.main.ts +++ b/apps/desktop/src/main/tray.main.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import * as path from "path"; +import * as url from "url"; import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron"; import { firstValueFrom } from "rxjs"; @@ -9,6 +10,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { BiometricStateService, BiometricsService } from "@bitwarden/key-management"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; +import { cleanUserAgent, isDev } from "../utils"; import { WindowMain } from "./window.main"; @@ -49,6 +51,11 @@ export class TrayMain { label: this.i18nService.t("showHide"), click: () => this.toggleWindow(), }, + { + visible: isDev(), + label: "Fake Popup", + click: () => this.fakePopup(), + }, { type: "separator" }, { label: this.i18nService.t("exit"), @@ -190,7 +197,7 @@ export class TrayMain { this.hideDock(); } } else { - this.windowMain.win.show(); + this.windowMain.show(); if (this.isDarwin()) { this.showDock(); } @@ -203,4 +210,38 @@ export class TrayMain { this.windowMain.win.close(); } } + + /** + * This method is used to test modal behavior during development and could be removed in the future. + * @returns + */ + private async fakePopup() { + if (this.windowMain.win == null || this.windowMain.win.isDestroyed()) { + await this.windowMain.createWindow("modal-app"); + return; + } + + // Restyle existing + const existingWin = this.windowMain.win; + + await this.desktopSettingsService.setInModalMode(true); + await existingWin.loadURL( + url.format({ + protocol: "file:", + //pathname: `${__dirname}/index.html`, + pathname: path.join(__dirname, "/index.html"), + slashes: true, + hash: "/passkeys", + query: { + redirectUrl: "/passkeys", + }, + }), + { + userAgent: cleanUserAgent(existingWin.webContents.userAgent), + }, + ); + existingWin.once("ready-to-show", () => { + existingWin.show(); + }); + } } diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 17f74b78d4c..ca154400ff5 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -5,7 +5,7 @@ import * as path from "path"; import * as url from "url"; import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron"; -import { firstValueFrom } from "rxjs"; +import { concatMap, firstValueFrom, pairwise } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; @@ -14,6 +14,7 @@ import { processisolations } from "@bitwarden/desktop-napi"; import { BiometricStateService } from "@bitwarden/key-management"; import { WindowState } from "../platform/models/domain/window-state"; +import { applyMainWindowStyles, applyPopupModalStyles } from "../platform/popup-modal-styles"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; import { cleanUserAgent, isDev, isLinux, isMac, isMacAppStore, isWindows } from "../utils"; @@ -77,6 +78,24 @@ export class WindowMain { } }); + this.desktopSettingsService.inModalMode$ + .pipe( + pairwise(), + concatMap(async ([lastValue, newValue]) => { + if (lastValue && !newValue) { + // Reset the window state to the main window state + applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]); + // Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode. + this.win.hide(); + } else if (!lastValue && newValue) { + // Apply the popup modal styles + applyPopupModalStyles(this.win); + this.win.show(); + } + }), + ) + .subscribe(); + this.desktopSettingsService.preventScreenshots$.subscribe((prevent) => { if (this.win == null) { return; @@ -182,7 +201,20 @@ export class WindowMain { }); } - async createWindow(): Promise { + /// Show the window with main window styles + show() { + if (this.win != null) { + applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]); + this.win.show(); + } + } + + /** + * Creates the main window. The template argument is used to determine the styling of the window and what url will be loaded. + * When the template is "modal-app", the window will be styled as a modal and the passkeys page will be loaded. + * TODO: We might want to refactor the template argument to accomodate more target pages, e.g. ssh-agent. + */ + async createWindow(template: "full-app" | "modal-app" = "full-app"): Promise { this.windowStates[mainWindowSizeKey] = await this.getWindowState( this.defaultWidth, this.defaultHeight, @@ -216,6 +248,12 @@ export class WindowMain { }, }); + if (template === "modal-app") { + applyPopupModalStyles(this.win); + } else { + applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]); + } + this.win.webContents.on("dom-ready", () => { this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0; }); @@ -225,21 +263,41 @@ export class WindowMain { } // Show it later since it might need to be maximized. - this.win.show(); + // use once event to avoid flash on unstyled content. + this.win.once("ready-to-show", () => { + this.win.show(); + }); - // and load the index.html of the app. - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.win.loadURL( - url.format({ - protocol: "file:", - pathname: path.join(__dirname, "/index.html"), - slashes: true, - }), - { - userAgent: cleanUserAgent(this.win.webContents.userAgent), - }, - ); + if (template === "full-app") { + // and load the index.html of the app. + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + void this.win.loadURL( + url.format({ + protocol: "file:", + pathname: path.join(__dirname, "/index.html"), + slashes: true, + }), + { + userAgent: cleanUserAgent(this.win.webContents.userAgent), + }, + ); + } else { + // we're in modal mode - load the passkeys page + await this.win.loadURL( + url.format({ + protocol: "file:", + pathname: path.join(__dirname, "/index.html"), + slashes: true, + hash: "/passkeys", + query: { + redirectUrl: "/passkeys", + }, + }), + { + userAgent: cleanUserAgent(this.win.webContents.userAgent), + }, + ); + } // Open the DevTools. if (isDev()) { @@ -336,6 +394,12 @@ export class WindowMain { return; } + const inModalMode = await firstValueFrom(this.desktopSettingsService.inModalMode$); + + if (inModalMode) { + return; + } + try { const bounds = win.getBounds(); @@ -346,9 +410,14 @@ export class WindowMain { } } - this.windowStates[configKey].isMaximized = win.isMaximized(); + // We treat fullscreen as maximized (would be even better to store isFullscreen as its own flag). + this.windowStates[configKey].isMaximized = win.isMaximized() || win.isFullScreen(); this.windowStates[configKey].displayBounds = screen.getDisplayMatching(bounds).bounds; + // Maybe store these as well? + // win.isFocused(); + // win.isVisible(); + if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) { this.windowStates[configKey].x = bounds.x; this.windowStates[configKey].y = bounds.y; diff --git a/apps/desktop/src/platform/popup-modal-styles.ts b/apps/desktop/src/platform/popup-modal-styles.ts new file mode 100644 index 00000000000..9c3f06b34bf --- /dev/null +++ b/apps/desktop/src/platform/popup-modal-styles.ts @@ -0,0 +1,52 @@ +import { BrowserWindow } from "electron"; + +import { WindowState } from "./models/domain/window-state"; + +// change as needed, however limited by mainwindow minimum size +const popupWidth = 680; +const popupHeight = 500; + +export function applyPopupModalStyles(window: BrowserWindow) { + window.unmaximize(); + window.setSize(popupWidth, popupHeight); + window.center(); + window.setWindowButtonVisibility?.(false); + window.setMenuBarVisibility?.(false); + window.setResizable(false); + window.setAlwaysOnTop(true); + + // Adjusting from full screen is a bit more hassle + if (window.isFullScreen()) { + window.setFullScreen(false); + window.once("leave-full-screen", () => { + window.setSize(popupWidth, popupHeight); + window.center(); + }); + } +} + +export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) { + window.setMinimumSize(680, 500); + + // need to guard against null/undefined values + + if (existingWindowState?.width && existingWindowState?.height) { + window.setSize(existingWindowState.width, existingWindowState.height); + } + + if (existingWindowState?.x && existingWindowState?.y) { + window.setPosition(existingWindowState.x, existingWindowState.y); + } + + window.setWindowButtonVisibility?.(true); + window.setMenuBarVisibility?.(true); + window.setResizable(true); + window.setAlwaysOnTop(false); + + // We're currently not recovering the maximized state, mostly due to conflicts with hiding the window. + // window.setFullScreen(existingWindowState.isMaximized); + + // if (existingWindowState.isMaximized) { + // window.maximize(); + // } +} diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 05dcd484def..bf81021922f 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -1,4 +1,3 @@ -import { sshagent as ssh } from "desktop_native/napi"; import { ipcRenderer } from "electron"; import { DeviceType } from "@bitwarden/common/enums"; @@ -64,13 +63,6 @@ const sshAgent = { clearKeys: async () => { return await ipcRenderer.invoke("sshagent.clearkeys"); }, - importKey: async (key: string, password: string): Promise => { - const res = await ipcRenderer.invoke("sshagent.importkey", { - privateKey: key, - password: password, - }); - return res; - }, isLoaded(): Promise { return ipcRenderer.invoke("sshagent.isloaded"); }, diff --git a/apps/desktop/src/platform/services/desktop-settings.service.ts b/apps/desktop/src/platform/services/desktop-settings.service.ts index f0d5d124de2..efac0cda252 100644 --- a/apps/desktop/src/platform/services/desktop-settings.service.ts +++ b/apps/desktop/src/platform/services/desktop-settings.service.ts @@ -75,6 +75,10 @@ const MINIMIZE_ON_COPY = new UserKeyDefinition(DESKTOP_SETTINGS_DISK, " clearOn: [], // User setting, no need to clear }); +const IN_MODAL_MODE = new KeyDefinition(DESKTOP_SETTINGS_DISK, "inModalMode", { + deserializer: (b) => b, +}); + const PREVENT_SCREENSHOTS = new KeyDefinition( DESKTOP_SETTINGS_DISK, "preventScreenshots", @@ -170,6 +174,10 @@ export class DesktopSettingsService { */ minimizeOnCopy$ = this.minimizeOnCopyState.state$.pipe(map(Boolean)); + private readonly inModalModeState = this.stateProvider.getGlobal(IN_MODAL_MODE); + + inModalMode$ = this.inModalModeState.state$.pipe(map(Boolean)); + constructor(private stateProvider: StateProvider) { this.window$ = this.windowState.state$.pipe( map((window) => @@ -178,6 +186,14 @@ export class DesktopSettingsService { ); } + /** + * This is used to clear the setting on application start to make sure we don't end up + * stuck in modal mode if the application is force-closed in modal mode. + */ + async resetInModalMode() { + await this.inModalModeState.update(() => false); + } + async setHardwareAcceleration(enabled: boolean) { await this.hwState.update(() => enabled); } @@ -286,6 +302,14 @@ export class DesktopSettingsService { await this.stateProvider.getUser(userId, MINIMIZE_ON_COPY).update(() => value); } + /** + * Sets the modal mode of the application. Setting this changes the windows-size and other properties. + * @param value `true` if the application is in modal mode, `false` if it is not. + */ + async setInModalMode(value: boolean) { + await this.inModalModeState.update(() => value); + } + /** * Sets the setting for whether or not the screenshot protection is enabled. * @param value `true` if the screenshot protection is enabled, `false` if it is not. diff --git a/apps/desktop/src/scss/pages.scss b/apps/desktop/src/scss/pages.scss index fda75e834f3..5f8f501d420 100644 --- a/apps/desktop/src/scss/pages.scss +++ b/apps/desktop/src/scss/pages.scss @@ -1,7 +1,6 @@ @import "variables.scss"; #login-page, -#login-with-device-page, #lock-page, #sso-page, #set-password-page, @@ -191,7 +190,6 @@ } #login-page, -#login-with-device-page, #login-decryption-options-page { flex-direction: column; justify-content: unset; @@ -222,41 +220,6 @@ } } -#login-with-device-page { - .content { - display: block; - padding-top: 70px; - width: 350px !important; - - .fingerprint { - margin: auto; - width: 315px; - - .fingerpint-header { - padding-left: 15px; - } - } - - .section { - margin-bottom: 30px; - } - - .another-method { - display: flex; - margin: auto; - .description-text { - padding-right: 5px; - } - } - - code { - @include themify($themes) { - color: themed("codeColor"); - } - } - } -} - #login-approval-page { .section-title { padding: 20px; diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.html b/apps/desktop/src/vault/app/vault/add-edit.component.html index 6244f585bae..d79e15ebe6a 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.html +++ b/apps/desktop/src/vault/app/vault/add-edit.component.html @@ -512,6 +512,15 @@ [ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }" > +
@@ -559,16 +568,6 @@
-
- -
diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.ts b/apps/desktop/src/vault/app/vault/add-edit.component.ts index ae332c9723b..2c8b5a8321a 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -3,8 +3,6 @@ import { DatePipe } from "@angular/common"; import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { NgForm } from "@angular/forms"; -import { sshagent as sshAgent } from "desktop_native/napi"; -import { lastValueFrom } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; @@ -25,8 +23,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; const BroadcasterSubscriptionId = "AddEditComponent"; @@ -60,6 +57,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On toastService: ToastService, cipherAuthorizationService: CipherAuthorizationService, sdkService: SdkService, + sshImportPromptService: SshImportPromptService, ) { super( cipherService, @@ -82,6 +80,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On cipherAuthorizationService, toastService, sdkService, + sshImportPromptService, ); } @@ -159,69 +158,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On this.cipher.revisionDate = cipher.revisionDate; } - async importSshKeyFromClipboard(password: string = "") { - const key = await this.platformUtilsService.readFromClipboard(); - const parsedKey = await ipc.platform.sshAgent.importKey(key, password); - if (parsedKey == null) { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("invalidSshKey"), - }); - return; - } - - switch (parsedKey.status) { - case sshAgent.SshKeyImportStatus.ParsingError: - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("invalidSshKey"), - }); - return; - case sshAgent.SshKeyImportStatus.UnsupportedKeyType: - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("sshKeyTypeUnsupported"), - }); - return; - case sshAgent.SshKeyImportStatus.PasswordRequired: - case sshAgent.SshKeyImportStatus.WrongPassword: - if (password !== "") { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("sshKeyWrongPassword"), - }); - } else { - password = await this.getSshKeyPassword(); - if (password === "") { - return; - } - await this.importSshKeyFromClipboard(password); - } - return; - default: - this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey; - this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey; - this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint; - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("sshKeyPasted"), - }); - } - } - - async getSshKeyPassword(): Promise { - const dialog = this.dialogService.open(SshKeyPasswordPromptComponent, { - ariaModal: true, - }); - - return await lastValueFrom(dialog.closed); - } - truncateString(value: string, length: number) { return value.length > length ? value.substring(0, length) + "..." : value; } diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html index 55e10980ad1..c3dcd191dfc 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html @@ -82,7 +82,6 @@
  • - - -
    -

    {{ "or" | i18n }}

    - - - {{ "logInWithPasskey" | i18n }} - -
    - -
    - -

    - {{ "newAroundHere" | i18n }} - - - {{ "createAccount" | i18n }} - -

    - - -
    -
    - - {{ "masterPass" | i18n }} - - - - {{ "getMasterPasswordHint" | i18n }} -
    - -
    - -
    - -
    - -
    - -
    - -
    - - - -
    - -
    -

    {{ "loggingInAs" | i18n }} {{ loggedEmail }}

    - {{ "notYou" | i18n }} -
    -
    - diff --git a/apps/web/src/app/auth/login/login-v1.component.ts b/apps/web/src/app/auth/login/login-v1.component.ts deleted file mode 100644 index 247aee4828c..00000000000 --- a/apps/web/src/app/auth/login/login-v1.component.ts +++ /dev/null @@ -1,224 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, NgZone, OnInit } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { takeUntil } from "rxjs"; -import { first } from "rxjs/operators"; - -import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component"; -import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, -} from "@bitwarden/auth/common"; -import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; -import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; -import { UserId } from "@bitwarden/common/types/guid"; -import { ToastService } from "@bitwarden/components"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; - -import { RouterService } from "../../core"; -import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service"; -import { OrganizationInvite } from "../organization-invite/organization-invite"; - -@Component({ - selector: "app-login", - templateUrl: "login-v1.component.html", -}) -export class LoginComponentV1 extends BaseLoginComponent implements OnInit { - showResetPasswordAutoEnrollWarning = false; - enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions; - policies: Policy[]; - - constructor( - private acceptOrganizationInviteService: AcceptOrganizationInviteService, - devicesApiService: DevicesApiServiceAbstraction, - appIdService: AppIdService, - loginStrategyService: LoginStrategyServiceAbstraction, - router: Router, - i18nService: I18nService, - route: ActivatedRoute, - platformUtilsService: PlatformUtilsService, - environmentService: EnvironmentService, - passwordGenerationService: PasswordGenerationServiceAbstraction, - private passwordStrengthService: PasswordStrengthServiceAbstraction, - cryptoFunctionService: CryptoFunctionService, - private policyApiService: PolicyApiServiceAbstraction, - private policyService: InternalPolicyService, - logService: LogService, - ngZone: NgZone, - protected stateService: StateService, - private routerService: RouterService, - formBuilder: FormBuilder, - formValidationErrorService: FormValidationErrorsService, - loginEmailService: LoginEmailServiceAbstraction, - ssoLoginService: SsoLoginServiceAbstraction, - toastService: ToastService, - ) { - super( - devicesApiService, - appIdService, - loginStrategyService, - router, - platformUtilsService, - i18nService, - stateService, - environmentService, - passwordGenerationService, - cryptoFunctionService, - logService, - ngZone, - formBuilder, - formValidationErrorService, - route, - loginEmailService, - ssoLoginService, - toastService, - ); - this.onSuccessfulLoginNavigate = this.goAfterLogIn; - } - - submitForm = async (showToast = true) => { - return await this.submitFormHelper(showToast); - }; - - private async submitFormHelper(showToast: boolean) { - await super.submit(showToast); - } - - async ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - // If there is a query parameter called 'org', set previousUrl to `/create-organization?org=paramValue` - if (qParams.org != null) { - const route = this.router.createUrlTree(["create-organization"], { - queryParams: { plan: qParams.org }, - }); - this.routerService.setPreviousUrl(route.toString()); - } - - /** - * If there is a query parameter called 'sponsorshipToken', that means they are coming - * from an email for sponsoring a families organization. If so, then set the prevousUrl - * to `/setup/families-for-enterprise?token=paramValue` - */ - if (qParams.sponsorshipToken != null) { - const route = this.router.createUrlTree(["setup/families-for-enterprise"], { - queryParams: { token: qParams.sponsorshipToken }, - }); - this.routerService.setPreviousUrl(route.toString()); - } - - await super.ngOnInit(); - }); - - // If there's an existing org invite, use it to get the password policies - const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite(); - if (orgInvite != null) { - await this.initPasswordPolicies(orgInvite); - } - } - - async goAfterLogIn(userId: UserId) { - const masterPassword = this.formGroup.value.masterPassword; - - // Check master password against policy - if (this.enforcedPasswordPolicyOptions != null) { - const strengthResult = this.passwordStrengthService.getPasswordStrength( - masterPassword, - this.formGroup.value.email, - ); - const masterPasswordScore = strengthResult == null ? null : strengthResult.score; - - // If invalid, save policies and require update - if ( - !this.policyService.evaluateMasterPassword( - masterPasswordScore, - masterPassword, - this.enforcedPasswordPolicyOptions, - ) - ) { - const policiesData: { [id: string]: PolicyData } = {}; - this.policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p))); - await this.policyService.replace(policiesData, userId); - await this.router.navigate(["update-password"]); - return; - } - } - - this.loginEmailService.clearValues(); - await this.router.navigate([this.successRoute]); - } - - async goToHint() { - await this.saveEmailSettings(); - await this.router.navigateByUrl("/hint"); - } - - async goToRegister() { - if (this.emailFormControl.valid) { - await this.router.navigate(["/signup"], { - queryParams: { email: this.emailFormControl.value }, - }); - return; - } - - await this.router.navigate(["/signup"]); - } - - protected override async handleMigrateEncryptionKey(result: AuthResult): Promise { - if (!result.requiresEncryptionKeyMigration) { - return false; - } - await this.router.navigate(["migrate-legacy-encryption"]); - return true; - } - - private async initPasswordPolicies(invite: OrganizationInvite): Promise { - try { - this.policies = await this.policyApiService.getPoliciesByToken( - invite.organizationId, - invite.token, - invite.email, - invite.organizationUserId, - ); - } catch (e) { - this.logService.error(e); - } - - if (this.policies == null) { - return; - } - - const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions( - this.policies, - invite.organizationId, - ); - - // Set to true if policy enabled and auto-enroll enabled - this.showResetPasswordAutoEnrollWarning = - resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled; - - this.policyService - .masterPasswordPolicyOptions$(this.policies) - .pipe(takeUntil(this.destroy$)) - .subscribe((enforcedPasswordPolicyOptions) => { - this.enforcedPasswordPolicyOptions = enforcedPasswordPolicyOptions; - }); - } -} diff --git a/apps/web/src/app/auth/login/login-via-auth-request-v1.component.html b/apps/web/src/app/auth/login/login-via-auth-request-v1.component.html deleted file mode 100644 index ed157eb9cf4..00000000000 --- a/apps/web/src/app/auth/login/login-via-auth-request-v1.component.html +++ /dev/null @@ -1,70 +0,0 @@ -
    -
    - - - -

    - {{ "loginOrCreateNewAccount" | i18n }} -

    - -
    -

    {{ "logInRequestSent" | i18n }}

    - -

    - {{ "notificationSentDeviceComplete" | i18n }} -

    - -
    -

    {{ "fingerprintPhraseHeader" | i18n }}

    -

    - {{ fingerprintPhrase }} -

    -
    - - - -
    - -
    - {{ "loginWithDeviceEnabledNote" | i18n }} - {{ "viewAllLoginOptions" | i18n }} -
    -
    -
    - - -
    -

    {{ "adminApprovalRequested" | i18n }}

    - -
    -

    {{ "adminApprovalRequestSentToAdmins" | i18n }}

    -

    {{ "youWillBeNotifiedOnceApproved" | i18n }}

    -
    - -
    -

    {{ "fingerprintPhraseHeader" | i18n }}

    -

    - {{ fingerprintPhrase }} -

    -
    - -
    - -
    - {{ "troubleLoggingIn" | i18n }} - {{ "viewAllLoginOptions" | i18n }} -
    -
    -
    -
    -
    diff --git a/apps/web/src/app/auth/login/login-via-auth-request-v1.component.ts b/apps/web/src/app/auth/login/login-via-auth-request-v1.component.ts deleted file mode 100644 index 8a8883e035b..00000000000 --- a/apps/web/src/app/auth/login/login-via-auth-request-v1.component.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Component } from "@angular/core"; - -import { LoginViaAuthRequestComponentV1 as BaseLoginViaAuthRequestComponentV1 } from "@bitwarden/angular/auth/components/login-via-auth-request-v1.component"; - -@Component({ - selector: "app-login-via-auth-request", - templateUrl: "login-via-auth-request-v1.component.html", -}) -export class LoginViaAuthRequestComponentV1 extends BaseLoginViaAuthRequestComponentV1 {} diff --git a/apps/web/src/app/auth/login/login.module.ts b/apps/web/src/app/auth/login/login.module.ts index a33a6b8a5a8..12f908324a3 100644 --- a/apps/web/src/app/auth/login/login.module.ts +++ b/apps/web/src/app/auth/login/login.module.ts @@ -5,23 +5,11 @@ import { CheckboxModule } from "@bitwarden/components"; import { SharedModule } from "../../../app/shared"; import { LoginDecryptionOptionsComponentV1 } from "./login-decryption-options/login-decryption-options-v1.component"; -import { LoginComponentV1 } from "./login-v1.component"; -import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component"; import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component"; @NgModule({ imports: [SharedModule, CheckboxModule], - declarations: [ - LoginComponentV1, - LoginViaAuthRequestComponentV1, - LoginDecryptionOptionsComponentV1, - LoginViaWebAuthnComponent, - ], - exports: [ - LoginComponentV1, - LoginViaAuthRequestComponentV1, - LoginDecryptionOptionsComponentV1, - LoginViaWebAuthnComponent, - ], + declarations: [LoginDecryptionOptionsComponentV1, LoginViaWebAuthnComponent], + exports: [LoginDecryptionOptionsComponentV1, LoginViaWebAuthnComponent], }) export class LoginModule {} diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index eb98f7fde07..f4a71d81e77 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -5,10 +5,10 @@ import { Router } from "@angular/router"; import { firstValueFrom, map } from "rxjs"; import { ChangePasswordComponent as BaseChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; @@ -50,7 +50,7 @@ export class ChangePasswordComponent private auditService: AuditService, private cipherService: CipherService, private syncService: SyncService, - private apiService: ApiService, + private masterPasswordApiService: MasterPasswordApiService, private router: Router, dialogService: DialogService, private userVerificationService: UserVerificationService, @@ -213,14 +213,14 @@ export class ChangePasswordComponent try { if (this.rotateUserKey) { - this.formPromise = this.apiService.postPassword(request).then(async () => { + this.formPromise = this.masterPasswordApiService.postPassword(request).then(async () => { // we need to save this for local masterkey verification during rotation await this.masterPasswordService.setMasterKeyHash(newLocalKeyHash, userId as UserId); await this.masterPasswordService.setMasterKey(newMasterKey, userId as UserId); return this.updateKey(); }); } else { - this.formPromise = this.apiService.postPassword(request); + this.formPromise = this.masterPasswordApiService.postPassword(request); } await this.formPromise; diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts index 0ca892b40bf..d2a6389806d 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts @@ -9,7 +9,13 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; -import { CipherViewComponent, DefaultTaskService, TaskService } from "@bitwarden/vault"; +import { + ChangeLoginPasswordService, + CipherViewComponent, + DefaultChangeLoginPasswordService, + DefaultTaskService, + TaskService, +} from "@bitwarden/vault"; import { WebViewPasswordHistoryService } from "../../../../vault/services/web-view-password-history.service"; @@ -34,6 +40,7 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService { { provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService }, { provide: PremiumUpgradePromptService, useClass: PremiumUpgradePromptNoop }, { provide: TaskService, useClass: DefaultTaskService }, + { provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService }, ], }) export class EmergencyViewDialogComponent { diff --git a/apps/web/src/app/auth/settings/security/device-management.component.ts b/apps/web/src/app/auth/settings/security/device-management.component.ts index 97107cc0c0b..0f31b8d4639 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.ts +++ b/apps/web/src/app/auth/settings/security/device-management.component.ts @@ -180,8 +180,20 @@ export class DeviceManagementComponent { private updateDeviceTable(devices: Array): void { this.dataSource.data = devices .map((device: DeviceView): DeviceTableData | null => { - if (!device.id || !device.type || !device.creationDate) { - this.validationService.showError(new Error("Invalid device data")); + if (device.id == undefined) { + this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing"))); + return null; + } + + if (device.type == undefined) { + this.validationService.showError(new Error(this.i18nService.t("deviceTypeMissing"))); + return null; + } + + if (device.creationDate == undefined) { + this.validationService.showError( + new Error(this.i18nService.t("deviceCreationDateMissing")), + ); return null; } diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index d4ade25aa1d..536e7b7020e 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -58,6 +58,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { BillingNotificationService } from "../services/billing-notification.service"; import { BillingSharedModule } from "../shared/billing-shared.module"; import { PaymentComponent } from "../shared/payment/payment.component"; @@ -208,6 +209,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private taxService: TaxServiceAbstraction, private accountService: AccountService, private organizationBillingService: OrganizationBillingService, + private billingNotificationService: BillingNotificationService, ) {} async ngOnInit(): Promise { @@ -228,10 +230,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { .organizations$(userId) .pipe(getOrganizationById(this.organizationId)), ); - const { accountCredit, paymentSource } = - await this.billingApiService.getOrganizationPaymentMethod(this.organizationId); - this.accountCredit = accountCredit; - this.paymentSource = paymentSource; + try { + const { accountCredit, paymentSource } = + await this.billingApiService.getOrganizationPaymentMethod(this.organizationId); + this.accountCredit = accountCredit; + this.paymentSource = paymentSource; + } catch (error) { + this.billingNotificationService.handleError(error); + } } if (!this.selfHosted) { diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts index a8b2c7a46f1..a13913dabf3 100644 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts +++ b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts @@ -23,6 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SyncService } from "@bitwarden/common/platform/sync"; import { DialogService, ToastService } from "@bitwarden/components"; +import { BillingNotificationService } from "../../services/billing-notification.service"; import { TrialFlowService } from "../../services/trial-flow.service"; import { AddCreditDialogResult, @@ -66,6 +67,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { private organizationService: OrganizationService, private accountService: AccountService, protected syncService: SyncService, + private billingNotificationService: BillingNotificationService, ) { this.activatedRoute.params .pipe( @@ -115,47 +117,52 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { protected load = async (): Promise => { this.loading = true; - const { accountCredit, paymentSource, subscriptionStatus } = - await this.billingApiService.getOrganizationPaymentMethod(this.organizationId); - this.accountCredit = accountCredit; - this.paymentSource = paymentSource; - this.subscriptionStatus = subscriptionStatus; + try { + const { accountCredit, paymentSource, subscriptionStatus } = + await this.billingApiService.getOrganizationPaymentMethod(this.organizationId); + this.accountCredit = accountCredit; + this.paymentSource = paymentSource; + this.subscriptionStatus = subscriptionStatus; - if (this.organizationId) { - const organizationSubscriptionPromise = this.organizationApiService.getSubscription( - this.organizationId, - ); - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - const organizationPromise = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.organizationId)), - ); + if (this.organizationId) { + const organizationSubscriptionPromise = this.organizationApiService.getSubscription( + this.organizationId, + ); + const userId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const organizationPromise = await firstValueFrom( + this.organizationService + .organizations$(userId) + .pipe(getOrganizationById(this.organizationId)), + ); - [this.organizationSubscriptionResponse, this.organization] = await Promise.all([ - organizationSubscriptionPromise, - organizationPromise, - ]); - this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( - this.organization, - this.organizationSubscriptionResponse, - paymentSource, - ); + [this.organizationSubscriptionResponse, this.organization] = await Promise.all([ + organizationSubscriptionPromise, + organizationPromise, + ]); + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + this.organization, + this.organizationSubscriptionResponse, + paymentSource, + ); + } + this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false; + // If the flag `launchPaymentModalAutomatically` is set to true, + // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. + // This delay ensures that any prior UI/rendering operations complete before triggering the modal. + if (this.launchPaymentModalAutomatically) { + window.setTimeout(async () => { + await this.changePayment(); + this.launchPaymentModalAutomatically = false; + this.location.replaceState(this.location.path(), "", {}); + }, 800); + } + } catch (error) { + this.billingNotificationService.handleError(error); + } finally { + this.loading = false; } - this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false; - // If the flag `launchPaymentModalAutomatically` is set to true, - // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. - // This delay ensures that any prior UI/rendering operations complete before triggering the modal. - if (this.launchPaymentModalAutomatically) { - window.setTimeout(async () => { - await this.changePayment(); - this.launchPaymentModalAutomatically = false; - this.location.replaceState(this.location.path(), "", {}); - }, 800); - } - this.loading = false; }; protected updatePaymentMethod = async (): Promise => { diff --git a/apps/web/src/app/billing/services/billing-notification.service.spec.ts b/apps/web/src/app/billing/services/billing-notification.service.spec.ts new file mode 100644 index 00000000000..d3a4d461574 --- /dev/null +++ b/apps/web/src/app/billing/services/billing-notification.service.spec.ts @@ -0,0 +1,76 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ToastService } from "@bitwarden/components"; + +import { BillingNotificationService } from "./billing-notification.service"; + +describe("BillingNotificationService", () => { + let service: BillingNotificationService; + let logService: MockProxy; + let toastService: MockProxy; + + beforeEach(() => { + logService = mock(); + toastService = mock(); + service = new BillingNotificationService(logService, toastService); + }); + + describe("handleError", () => { + it("should log error and show toast for ErrorResponse", () => { + const error = new ErrorResponse(["test error"], 400); + + expect(() => service.handleError(error)).toThrow(); + expect(logService.error).toHaveBeenCalledWith(error); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: error.getSingleMessage(), + }); + }); + + it("shows error toast with the provided error", () => { + const error = new ErrorResponse(["test error"], 400); + + expect(() => service.handleError(error, "Test Title")).toThrow(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "Test Title", + message: error.getSingleMessage(), + }); + }); + + it("should only log error for non-ErrorResponse", () => { + const error = new Error("test error"); + + expect(() => service.handleError(error)).toThrow(); + expect(logService.error).toHaveBeenCalledWith(error); + expect(toastService.showToast).not.toHaveBeenCalled(); + }); + }); + + describe("showSuccess", () => { + it("shows success toast with default title when provided title is empty", () => { + const message = "test message"; + service.showSuccess(message); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: "", + message, + }); + }); + + it("should show success toast with custom title", () => { + const message = "test message"; + service.showSuccess(message, "Success Title"); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: "Success Title", + message, + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/billing-notification.service.ts b/apps/web/src/app/billing/services/billing-notification.service.ts new file mode 100644 index 00000000000..6695e516ca8 --- /dev/null +++ b/apps/web/src/app/billing/services/billing-notification.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from "@angular/core"; + +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ToastService } from "@bitwarden/components"; + +@Injectable({ + providedIn: "root", +}) +export class BillingNotificationService { + constructor( + private logService: LogService, + private toastService: ToastService, + ) {} + + handleError(error: unknown, title: string = "") { + this.logService.error(error); + if (error instanceof ErrorResponse) { + this.toastService.showToast({ + variant: "error", + title: title, + message: error.getSingleMessage(), + }); + } + throw error; + } + + showSuccess(message: string, title: string = "") { + this.toastService.showToast({ + variant: "success", + title: title, + message: message, + }); + } +} diff --git a/apps/web/src/app/billing/services/billing-services.module.ts b/apps/web/src/app/billing/services/billing-services.module.ts index 7412d47c79c..56b906cdaae 100644 --- a/apps/web/src/app/billing/services/billing-services.module.ts +++ b/apps/web/src/app/billing/services/billing-services.module.ts @@ -1,4 +1,8 @@ import { NgModule } from "@angular/core"; -@NgModule({}) +import { BillingNotificationService } from "./billing-notification.service"; + +@NgModule({ + providers: [BillingNotificationService], +}) export class BillingServicesModule {} diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 2cb1a4ee923..d253dd608a2 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -50,6 +50,7 @@ import { import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ClientType } from "@bitwarden/common/enums"; @@ -96,6 +97,7 @@ import { DefaultThemeStateService, ThemeStateService, } from "@bitwarden/common/platform/theming/theme-state.service"; +import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KdfConfigService, @@ -103,6 +105,7 @@ import { BiometricsService, } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; +import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; import { flagEnabled } from "../../utils/flags"; import { PolicyListService } from "../admin-console/core/policy-list.service"; @@ -277,6 +280,7 @@ const safeProviders: SafeProvider[] = [ useClass: WebSetPasswordJitService, deps: [ ApiService, + MasterPasswordApiService, KeyServiceAbstraction, EncryptService, I18nServiceAbstraction, @@ -349,6 +353,11 @@ const safeProviders: SafeProvider[] = [ useClass: WebLoginDecryptionOptionsService, deps: [MessagingService, RouterService, AcceptOrganizationInviteService], }), + safeProvider({ + provide: SshImportPromptService, + useClass: DefaultSshImportPromptService, + deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts b/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts index 36db7aaa311..72ceb2afe7d 100644 --- a/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts +++ b/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts @@ -3,6 +3,7 @@ import { FormControl, FormGroup, Validators } from "@angular/forms"; import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -75,7 +76,7 @@ export class MigrateFromLegacyEncryptionComponent { } catch (e) { // If the error is due to missing folders, we can delete all folders and try again if ( - e instanceof Error && + e instanceof ErrorResponse && e.message === "All existing folders must be included in the rotation." ) { const deleteFolders = await this.dialogService.openSimpleDialog({ diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index adb38ef43f0..c531f358b34 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -55,10 +55,6 @@ import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/ import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component"; import { CreateOrganizationComponent } from "./admin-console/settings/create-organization.component"; import { deepLinkGuard } from "./auth/guards/deep-link.guard"; -import { HintComponent } from "./auth/hint.component"; -import { LoginDecryptionOptionsComponentV1 } from "./auth/login/login-decryption-options/login-decryption-options-v1.component"; -import { LoginComponentV1 } from "./auth/login/login-v1.component"; -import { LoginViaAuthRequestComponentV1 } from "./auth/login/login-via-auth-request-v1.component"; import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component"; import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component"; @@ -69,7 +65,6 @@ import { AccountComponent } from "./auth/settings/account/account.component"; import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emergency-access.component"; import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component"; import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module"; -import { SsoComponentV1 } from "./auth/sso-v1.component"; import { TwoFactorComponentV1 } from "./auth/two-factor-v1.component"; import { UpdatePasswordComponent } from "./auth/update-password.component"; import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component"; @@ -172,172 +167,6 @@ const routes: Routes = [ }, ], }, - ...unauthUiRefreshSwap( - LoginViaAuthRequestComponentV1, - AnonLayoutWrapperComponent, - { - path: "login-with-device", - data: { titleId: "loginWithDevice" } satisfies RouteDataProperties, - }, - { - path: "login-with-device", - data: { - pageIcon: DevicesIcon, - pageTitle: { - key: "logInRequestSent", - }, - pageSubtitle: { - key: "aNotificationWasSentToYourDevice", - }, - titleId: "loginInitiated", - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { path: "", component: LoginViaAuthRequestComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginViaAuthRequestComponentV1, - AnonLayoutWrapperComponent, - { - path: "admin-approval-requested", - data: { titleId: "adminApprovalRequested" } satisfies RouteDataProperties, - }, - { - path: "admin-approval-requested", - data: { - pageIcon: DevicesIcon, - pageTitle: { - key: "adminApprovalRequested", - }, - pageSubtitle: { - key: "adminApprovalRequestSentToAdmins", - }, - titleId: "adminApprovalRequested", - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [{ path: "", component: LoginViaAuthRequestComponent }], - }, - ), - ...unauthUiRefreshSwap( - AnonLayoutWrapperComponent, - AnonLayoutWrapperComponent, - { - path: "login", - canActivate: [unauthGuardFn()], - children: [ - { - path: "", - component: LoginComponentV1, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - data: { - pageTitle: { - key: "logIn", - }, - }, - }, - { - path: "login", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "logInToBitwarden", - }, - pageIcon: VaultIcon, - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { - path: "", - component: LoginComponent, - }, - { - path: "", - component: LoginSecondaryContentComponent, - outlet: "secondary", - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginDecryptionOptionsComponentV1, - AnonLayoutWrapperComponent, - { - path: "login-initiated", - canActivate: [tdeDecryptionRequiredGuard()], - }, - { - path: "login-initiated", - canActivate: [tdeDecryptionRequiredGuard()], - data: { - pageIcon: DevicesIcon, - }, - children: [{ path: "", component: LoginDecryptionOptionsComponent }], - }, - ), - ...unauthUiRefreshSwap( - AnonLayoutWrapperComponent, - AnonLayoutWrapperComponent, - { - path: "hint", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "passwordHint", - }, - titleId: "passwordHint", - }, - children: [ - { path: "", component: HintComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - { - path: "", - children: [ - { - path: "hint", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "requestPasswordHint", - }, - pageSubtitle: { - key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", - }, - pageIcon: UserLockIcon, - state: "hint", - }, - children: [ - { path: "", component: PasswordHintComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ], - }, - ), { path: "", component: AnonLayoutWrapperComponent, @@ -381,6 +210,97 @@ const routes: Routes = [ }, ], }, + { + path: "login", + canActivate: [unauthGuardFn()], + data: { + pageTitle: { + key: "logInToBitwarden", + }, + pageIcon: VaultIcon, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { + path: "", + component: LoginComponent, + }, + { + path: "", + component: LoginSecondaryContentComponent, + outlet: "secondary", + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "login-with-device", + data: { + pageIcon: DevicesIcon, + pageTitle: { + key: "logInRequestSent", + }, + pageSubtitle: { + key: "aNotificationWasSentToYourDevice", + }, + titleId: "loginInitiated", + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { path: "", component: LoginViaAuthRequestComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "admin-approval-requested", + data: { + pageIcon: DevicesIcon, + pageTitle: { + key: "adminApprovalRequested", + }, + pageSubtitle: { + key: "adminApprovalRequestSentToAdmins", + }, + titleId: "adminApprovalRequested", + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [{ path: "", component: LoginViaAuthRequestComponent }], + }, + { + path: "hint", + canActivate: [unauthGuardFn()], + data: { + pageTitle: { + key: "requestPasswordHint", + }, + pageSubtitle: { + key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", + }, + pageIcon: UserLockIcon, + state: "hint", + }, + children: [ + { path: "", component: PasswordHintComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, { path: "send/:sendId/:key", data: { @@ -432,64 +352,24 @@ const routes: Routes = [ }, ], }, - ...unauthUiRefreshSwap( - SsoComponentV1, - SsoComponent, - { - path: "sso", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "enterpriseSingleSignOn", - }, - titleId: "enterpriseSingleSignOn", - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { - path: "", - component: SsoComponentV1, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - { - path: "sso", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "singleSignOn", - }, - titleId: "enterpriseSingleSignOn", - pageSubtitle: { - key: "singleSignOnEnterOrgIdentifierText", - }, - titleAreaMaxWidth: "md", - pageIcon: SsoKeyIcon, - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { - path: "", - component: SsoComponent, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ), { - path: "login", + path: "sso", canActivate: [unauthGuardFn()], + data: { + pageTitle: { + key: "singleSignOn", + }, + titleId: "enterpriseSingleSignOn", + pageSubtitle: { + key: "singleSignOnEnterOrgIdentifierText", + }, + titleAreaMaxWidth: "md", + pageIcon: SsoKeyIcon, + } satisfies RouteDataProperties & AnonLayoutWrapperData, children: [ { path: "", - component: LoginComponent, + component: SsoComponent, }, { path: "", @@ -497,11 +377,6 @@ const routes: Routes = [ outlet: "environment-selector", }, ], - data: { - pageTitle: { - key: "logIn", - }, - }, }, ...unauthUiRefreshSwap( TwoFactorComponentV1, 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 b3f2ae581b3..589704a409a 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 @@ -26,77 +26,73 @@ - + - - - {{ "name" | i18n }} - - {{ "owner" | i18n }} - - - {{ "timesExposed" | i18n }} - - + + {{ "name" | i18n }} + + {{ "owner" | i18n }} + + + {{ "timesExposed" | i18n }} + - - - - - - - - {{ r.name }} - - - {{ r.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ r.subTitle }} - - - + + + + + + {{ row.name }} - - - - - {{ "exposedXTimes" | i18n: (r.exposedXTimes | number) }} - - - + + + {{ row.name }} + + + + {{ "shared" | i18n }} + + + + {{ "attachments" | i18n }} + +
    + {{ row.subTitle }} + + + + + + + + {{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }} + +
    -
    + 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 af336c94854..ded456ff963 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 @@ -33,73 +33,69 @@ - + - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - {{ "timesReused" | i18n }} - + + {{ "name" | i18n }} + {{ "owner" | i18n }} + {{ "timesReused" | i18n }} - - - - - - - - {{ r.name }} - - - {{ r.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ r.subTitle }} - - - + + + + + + {{ row.name }} - - - - - {{ "reusedXTimes" | i18n: passwordUseMap.get(r.login.password) }} - - - + + + {{ row.name }} + + + + {{ "shared" | i18n }} + + + + {{ "attachments" | i18n }} + +
    + {{ row.subTitle }} + + + + + + + + {{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }} + +
    -
    + 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 9c5b587e60a..3ef1d11f9b2 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 @@ -31,77 +31,73 @@ - + - - - {{ "name" | i18n }} - - {{ "owner" | i18n }} - - - {{ "weakness" | i18n }} - - + + {{ "name" | i18n }} + + {{ "owner" | i18n }} + + + {{ "weakness" | i18n }} + - - - - - - - - {{ r.name }} - - - {{ r.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ r.subTitle }} - - - + + + + + + {{ row.name }} - - - - - {{ r.reportValue.label | i18n }} - - - + + + {{ row.name }} + + + + {{ "shared" | i18n }} + + + + {{ "attachments" | i18n }} + +
    + {{ row.subTitle }} + + + + + + + + {{ row.reportValue.label | i18n }} + +
    -
    + diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index 3df2b9a83c9..d8739465938 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -15,7 +15,6 @@ import { isCardExpired } from "@bitwarden/common/autofill/utils"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -30,7 +29,7 @@ import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; @Component({ selector: "app-vault-add-edit", @@ -76,6 +75,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On cipherAuthorizationService: CipherAuthorizationService, toastService: ToastService, sdkService: SdkService, + sshImportPromptService: SshImportPromptService, ) { super( cipherService, @@ -98,6 +98,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On cipherAuthorizationService, toastService, sdkService, + sshImportPromptService, ); } @@ -105,17 +106,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On await super.ngOnInit(); await this.load(); - // https://bitwarden.atlassian.net/browse/PM-10413 - // cannot generate ssh keys so block creation - if ( - this.type === CipherType.SshKey && - this.cipherId == null && - !(await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem)) - ) { - this.type = CipherType.Login; - this.cipher.type = CipherType.Login; - } - this.viewOnly = !this.cipher.edit && this.editMode; // remove when all the title for all clients are updated to New Item if (this.cloneMode || !this.editMode) { diff --git a/apps/web/src/app/vault/individual-vault/password-history.component.html b/apps/web/src/app/vault/individual-vault/password-history.component.html index 4eaca8f736e..ccbe20c5192 100644 --- a/apps/web/src/app/vault/individual-vault/password-history.component.html +++ b/apps/web/src/app/vault/individual-vault/password-history.component.html @@ -1,4 +1,4 @@ - + {{ "passwordHistory" | i18n }} diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 9c002a4ed94..786c5de740e 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -7,7 +7,6 @@ import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -243,20 +242,17 @@ export class VaultFilterComponent implements OnInit, OnDestroy { }, { id: "note", - name: this.i18nService.t("typeSecureNote"), + name: this.i18nService.t("note"), type: CipherType.SecureNote, icon: "bwi-sticky-note", }, - ]; - - if (await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem)) { - allTypeFilters.push({ + { id: "sshKey", name: this.i18nService.t("typeSshKey"), type: CipherType.SshKey, icon: "bwi-key", - }); - } + }, + ]; const typeFilterSection: VaultFilterSection = { data$: this.vaultFilterService.buildTypeTree( @@ -281,7 +277,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { }, action: this.applyFolderFilter, edit: { - text: "editFolder", + filterName: this.i18nService.t("folder"), action: this.editFolder, }, }; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html index 6dd00375f44..1485c1f5343 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html @@ -89,7 +89,7 @@ *ngIf="editInfo && f.node.id" class="edit-button" (click)="onEdit(f)" - appA11yTitle="{{ editInfo.text | i18n }}" + appA11yTitle="{{ 'editWithName' | i18n: editInfo.filterName : f.node.name }}" > diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts index f89a72b5d2b..0f949e17146 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts @@ -31,7 +31,7 @@ export type VaultFilterSection = { }; action: (filterNode: TreeNode) => Promise; edit?: { - text: string; + filterName: string; action: (filter: VaultFilterType) => void; }; add?: { diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index 000feeaf337..489f42649f9 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -9,14 +9,28 @@ import { OnInit, Output, } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; -import { Unassigned, CollectionView } from "@bitwarden/admin-console/common"; +import { + Unassigned, + CollectionView, + CollectionAdminService, +} from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; -import { BreadcrumbsModule, MenuModule } from "@bitwarden/components"; +import { + BreadcrumbsModule, + DialogService, + MenuModule, + SimpleDialogOptions, +} from "@bitwarden/components"; import { CollectionDialogTabType } from "../../../admin-console/organizations/shared/components/collection-dialog"; import { HeaderModule } from "../../../layouts/header/header.module"; @@ -81,7 +95,13 @@ export class VaultHeaderComponent implements OnInit { /** Emits an event when the delete collection button is clicked in the header */ @Output() onDeleteCollection = new EventEmitter(); - constructor(private i18nService: I18nService) {} + constructor( + private i18nService: I18nService, + private collectionAdminService: CollectionAdminService, + private dialogService: DialogService, + private router: Router, + private configService: ConfigService, + ) {} async ngOnInit() {} @@ -199,6 +219,56 @@ export class VaultHeaderComponent implements OnInit { } async addCollection(): Promise { + const organization = this.organizations?.find( + (org) => org.productTierType === ProductTierType.Free, + ); + const isBreadcrumbEventLogsEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs), + ); + if ( + this.organizations.length == 1 && + organization.productTierType === ProductTierType.Free && + isBreadcrumbEventLogsEnabled + ) { + const collections = await this.collectionAdminService.getAll(organization.id); + if (collections.length === organization.maxCollections) { + await this.showFreeOrgUpgradeDialog(organization); + return; + } + } this.onAddCollection.emit(); } + + private async showFreeOrgUpgradeDialog(organization: Organization): Promise { + const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = { + title: this.i18nService.t("upgradeOrganization"), + content: this.i18nService.t( + organization.canEditSubscription + ? "freeOrgMaxCollectionReachedManageBilling" + : "freeOrgMaxCollectionReachedNoManageBilling", + organization.maxCollections, + ), + type: "primary", + }; + + if (organization.canEditSubscription) { + orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade"); + } else { + orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok"); + orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn + } + + const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts); + const result: boolean | undefined = await firstValueFrom(simpleDialog.closed); + + if (!result) { + return; + } + + if (organization.canEditSubscription) { + await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], { + queryParams: { upgrade: true }, + }); + } + } } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 9983567a4d1..b74af0b1b9c 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -24,6 +24,7 @@ import { take, takeUntil, tap, + catchError, } from "rxjs/operators"; import { @@ -80,6 +81,7 @@ import { CollectionDialogTabType, openCollectionDialog, } from "../../admin-console/organizations/shared/components/collection-dialog"; +import { BillingNotificationService } from "../../billing/services/billing-notification.service"; import { TrialFlowService } from "../../billing/services/trial-flow.service"; import { FreeTrial } from "../../billing/types/free-trial"; import { SharedModule } from "../../shared/shared.module"; @@ -213,20 +215,25 @@ export class VaultComponent implements OnInit, OnDestroy { ownerOrgs.map((org) => combineLatest([ this.organizationApiService.getSubscription(org.id), - this.organizationBillingService.getPaymentSource(org.id), + from(this.organizationBillingService.getPaymentSource(org.id)).pipe( + catchError((error: unknown) => { + this.billingNotificationService.handleError(error); + return of(null); + }), + ), ]).pipe( - map(([subscription, paymentSource]) => { - return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + map(([subscription, paymentSource]) => + this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( org, subscription, paymentSource, - ); - }), + ), + ), ), ), ); }), - map((results) => results.filter((result) => result.shownBanner)), + map((results) => results.filter((result) => result !== null && result.shownBanner)), shareReplay({ refCount: false, bufferSize: 1 }), ); @@ -262,6 +269,7 @@ export class VaultComponent implements OnInit, OnDestroy { protected billingApiService: BillingApiServiceAbstraction, private trialFlowService: TrialFlowService, private organizationBillingService: OrganizationBillingServiceAbstraction, + private billingNotificationService: BillingNotificationService, ) {} async ngOnInit() { diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index 8490ec6c9db..89f3b79f1fb 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -28,7 +28,7 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; import { AddEditComponent as BaseAddEditComponent } from "../individual-vault/add-edit.component"; @@ -64,6 +64,7 @@ export class AddEditComponent extends BaseAddEditComponent { cipherAuthorizationService: CipherAuthorizationService, toastService: ToastService, sdkService: SdkService, + sshImportPromptService: SshImportPromptService, ) { super( cipherService, @@ -88,6 +89,7 @@ export class AddEditComponent extends BaseAddEditComponent { cipherAuthorizationService, toastService, sdkService, + sshImportPromptService, ); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 229cca65ae5..3e47e2b2279 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -38,7 +38,7 @@ "restoreMembers": { "message": "Restore members" }, - "cannotRestoreAccessError":{ + "cannotRestoreAccessError": { "message": "Cannot restore organization access" }, "allApplicationsWithCount": { @@ -449,6 +449,9 @@ "dragToSort": { "message": "Drag to sort" }, + "dragToReorder": { + "message": "Drag to reorder" + }, "cfTypeText": { "message": "Text" }, @@ -491,6 +494,19 @@ "editFolder": { "message": "Edit folder" }, + "editWithName": { + "message": "Edit $ITEM$: $NAME$", + "placeholders": { + "item": { + "content": "$1", + "example": "login" + }, + "name": { + "content": "$2", + "example": "Social" + } + } + }, "newFolder": { "message": "New folder" }, @@ -1355,8 +1371,8 @@ "yourAccountIsLocked": { "message": "Your account is locked" }, - "uuid":{ - "message" : "UUID" + "uuid": { + "message": "UUID" }, "unlock": { "message": "Unlock" @@ -4551,6 +4567,40 @@ } } }, + "reorderFieldUp": { + "message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + }, + "index": { + "content": "$2", + "example": "1" + }, + "length": { + "content": "$3", + "example": "3" + } + } + }, + "reorderFieldDown": { + "message": "$LABEL$ moved down, position $INDEX$ of $LENGTH$", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + }, + "index": { + "content": "$2", + "example": "1" + }, + "length": { + "content": "$3", + "example": "3" + } + } + }, "keyUpdateFoldersFailed": { "message": "When updating your encryption key, your folders could not be decrypted. To continue with the update, your folders must be deleted. No vault items will be deleted if you proceed." }, @@ -5904,10 +5954,10 @@ "bulkFilteredMessage": { "message": "Excluded, not applicable for this action" }, - "nonCompliantMembersTitle":{ + "nonCompliantMembersTitle": { "message": "Non-compliant members" }, - "nonCompliantMembersError":{ + "nonCompliantMembersError": { "message": "Members that are non-compliant with the Single organization or Two-step login policy cannot be restored until they adhere to the policy requirements" }, "fingerprint": { @@ -8631,9 +8681,6 @@ "adminApprovalRequestSentToAdmins": { "message": "Your request has been sent to your admin." }, - "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." - }, "troubleLoggingIn": { "message": "Trouble logging in?" }, @@ -9330,7 +9377,7 @@ "message": "for Bitwarden using the implementation guide for your Identity Provider.", "description": "This represents the end of a sentence, broken up to include links. The full sentence will be 'Configure single sign-on for Bitwarden using the implementation guide for your Identity Provider." }, - "userProvisioning":{ + "userProvisioning": { "message": "User provisioning" }, "scimIntegration": { @@ -9344,31 +9391,40 @@ "message": "(System for Cross-domain Identity Management) to automatically provision users and groups to Bitwarden using the implementation guide for your Identity Provider.", "description": "This represents the end of a sentence, broken up to include links. The full sentence will be 'Configure SCIM (System for Cross-domain Identity Management) to automatically provision users and groups to Bitwarden using the implementation guide for your Identity Provider" }, - "bwdc":{ + "bwdc": { "message": "Bitwarden Directory Connector" }, "bwdcDesc": { "message": "Configure Bitwarden Directory Connector to automatically provision users and groups using the implementation guide for your Identity Provider." }, - "eventManagement":{ + "eventManagement": { "message": "Event management" }, - "eventManagementDesc":{ + "eventManagementDesc": { "message": "Integrate Bitwarden event logs with your SIEM (system information and event management) system by using the implementation guide for your platform." }, - "deviceManagement":{ + "deviceManagement": { "message": "Device management" }, - "deviceManagementDesc":{ + "deviceManagementDesc": { "message": "Configure device management for Bitwarden using the implementation guide for your platform." }, + "deviceIdMissing": { + "message": "Device ID is missing" + }, + "deviceTypeMissing": { + "message": "Device type is missing" + }, + "deviceCreationDateMissing": { + "message": "Device creation date is missing" + }, "desktopRequired": { "message": "Desktop required" }, "reopenLinkOnDesktop": { "message": "Reopen this link from your email on a desktop." }, - "integrationCardTooltip":{ + "integrationCardTooltip": { "message": "Launch $INTEGRATION$ implementation guide.", "placeholders": { "integration": { @@ -9377,7 +9433,7 @@ } } }, - "smIntegrationTooltip":{ + "smIntegrationTooltip": { "message": "Set up $INTEGRATION$.", "placeholders": { "integration": { @@ -9386,7 +9442,7 @@ } } }, - "smSdkTooltip":{ + "smSdkTooltip": { "message": "View $SDK$ repository", "placeholders": { "sdk": { @@ -9395,7 +9451,7 @@ } } }, - "integrationCardAriaLabel":{ + "integrationCardAriaLabel": { "message": "open $INTEGRATION$ implementation guide in a new tab.", "placeholders": { "integration": { @@ -9404,7 +9460,7 @@ } } }, - "smSdkAriaLabel":{ + "smSdkAriaLabel": { "message": "view $SDK$ repository in a new tab.", "placeholders": { "sdk": { @@ -9413,7 +9469,7 @@ } } }, - "smIntegrationCardAriaLabel":{ + "smIntegrationCardAriaLabel": { "message": "set up $INTEGRATION$ implementation guide in a new tab.", "placeholders": { "integration": { @@ -9820,7 +9876,7 @@ "message": "Config" }, "learnMoreAboutEmergencyAccess": { - "message":"Learn more about emergency access" + "message": "Learn more about emergency access" }, "learnMoreAboutMatchDetection": { "message": "Learn more about match detection" @@ -10122,7 +10178,7 @@ "selfHostingTitleProper": { "message": "Self-Hosting" }, - "claim-domain-single-org-warning" : { + "claim-domain-single-org-warning": { "message": "Claiming a domain will turn on the single organization policy." }, "single-org-revoked-user-warning": { @@ -10363,6 +10419,36 @@ "organizationNameMaxLength": { "message": "Organization name cannot exceed 50 characters." }, + "sshKeyWrongPassword": { + "message": "The password you entered is incorrect." + }, + "importSshKey": { + "message": "Import" + }, + "confirmSshKeyPassword": { + "message": "Confirm password" + }, + "enterSshKeyPasswordDesc": { + "message": "Enter the password for the SSH key." + }, + "enterSshKeyPassword": { + "message": "Enter password" + }, + "invalidSshKey": { + "message": "The SSH key is invalid" + }, + "sshKeyTypeUnsupported": { + "message": "The SSH key type is not supported" + }, + "importSshKeyFromClipboard": { + "message": "Import key from clipboard" + }, + "sshKeyImported": { + "message": "SSH key imported successfully" + }, + "copySSHPrivateKey": { + "message": "Copy private key" + }, "openingExtension": { "message": "Opening the Bitwarden browser extension" }, @@ -10528,5 +10614,8 @@ }, "upgradeEventLogMessage":{ "message" : "These events are examples only and do not reflect real events within your Bitwarden organization." + }, + "cannotCreateCollection": { + "message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections." } } diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index 28fe5ce1f35..d172ea95c71 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -81,6 +81,7 @@ const moduleRules = [ loader: "babel-loader", options: { configFile: "../../babel.config.json", + cacheDirectory: NODE_ENV !== "production", }, }, ], @@ -349,6 +350,20 @@ const webpackConfig = { styles: ["./src/scss/styles.scss", "./src/scss/tailwind.css"], theme_head: "./src/theme.ts", }, + cache: + NODE_ENV === "production" + ? false + : { + type: "filesystem", + allowCollectingMemory: true, + cacheDirectory: path.resolve(__dirname, "../../node_modules/.cache/webpack"), + buildDependencies: { + config: [__filename], + }, + }, + snapshot: { + unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], + }, optimization: { splitChunks: { cacheGroups: { @@ -361,6 +376,7 @@ const webpackConfig = { }, }, }, + minimize: NODE_ENV === "production", minimizer: [ new TerserPlugin({ terserOptions: { diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html index 7723324781b..cafd0744a8f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html @@ -1,7 +1,7 @@ diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html index c292d51ebda..adf9fcd2dcf 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html @@ -24,7 +24,7 @@ @@ -74,7 +74,7 @@ {{ orgDomain.lastCheckedDate | date: "medium" }} - +
  • - + { + const actual = jest.requireActual("@angular/cdk/drag-drop"); + return { + ...actual, + moveItemInArray: jest.fn(actual.moveItemInArray), + }; +}); + describe("AutofillOptionsComponent", () => { let component: AutofillOptionsComponent; let fixture: ComponentFixture; @@ -255,4 +264,111 @@ describe("AutofillOptionsComponent", () => { expect(component.autofillOptionsForm.value.uris.length).toEqual(1); }); + + describe("Drag & Drop Functionality", () => { + beforeEach(() => { + // Prevent auto‑adding an empty URI by setting a non‑null initial value. + // This overrides the call to initNewCipher. + + // Now clear any existing URIs (including the auto‑added one) + component.autofillOptionsForm.controls.uris.clear(); + + // Add exactly three URIs that we want to test reordering on. + component.addUri({ uri: "https://first.com", matchDetection: null }); + component.addUri({ uri: "https://second.com", matchDetection: null }); + component.addUri({ uri: "https://third.com", matchDetection: null }); + fixture.detectChanges(); + }); + + it("should reorder URI inputs on drop event", () => { + // Simulate a drop event that moves the first URI (index 0) to the last position (index 2). + const dropEvent: CdkDragDrop = { + previousIndex: 0, + currentIndex: 2, + container: null, + previousContainer: null, + isPointerOverContainer: true, + item: null, + distance: { x: 0, y: 0 }, + } as any; + + component.onUriItemDrop(dropEvent); + fixture.detectChanges(); + + expect(moveItemInArray).toHaveBeenCalledWith( + component.autofillOptionsForm.controls.uris.controls, + 0, + 2, + ); + }); + + it("should reorder URI input via keyboard ArrowUp", async () => { + // Clear and add exactly two URIs. + component.autofillOptionsForm.controls.uris.clear(); + component.addUri({ uri: "https://first.com", matchDetection: null }); + component.addUri({ uri: "https://second.com", matchDetection: null }); + fixture.detectChanges(); + + // Simulate pressing ArrowUp on the second URI (index 1) + const keyEvent = { + key: "ArrowUp", + preventDefault: jest.fn(), + target: document.createElement("button"), + } as unknown as KeyboardEvent; + + // Force requestAnimationFrame to run synchronously + jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => { + cb(new Date().getTime()); + return 0; + }); + (liveAnnouncer.announce as jest.Mock).mockResolvedValue(null); + + await component.onUriItemKeydown(keyEvent, 1); + fixture.detectChanges(); + + expect(moveItemInArray).toHaveBeenCalledWith( + component.autofillOptionsForm.controls.uris.controls, + 1, + 0, + ); + expect(liveAnnouncer.announce).toHaveBeenCalledWith( + "reorderFieldUp websiteUri 1 2", + "assertive", + ); + }); + + it("should reorder URI input via keyboard ArrowDown", async () => { + // Clear and add exactly three URIs. + component.autofillOptionsForm.controls.uris.clear(); + component.addUri({ uri: "https://first.com", matchDetection: null }); + component.addUri({ uri: "https://second.com", matchDetection: null }); + component.addUri({ uri: "https://third.com", matchDetection: null }); + fixture.detectChanges(); + + // Simulate pressing ArrowDown on the second URI (index 1) + const keyEvent = { + key: "ArrowDown", + preventDefault: jest.fn(), + target: document.createElement("button"), + } as unknown as KeyboardEvent; + + jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => { + cb(new Date().getTime()); + return 0; + }); + (liveAnnouncer.announce as jest.Mock).mockResolvedValue(null); + + await component.onUriItemKeydown(keyEvent, 1); + + expect(moveItemInArray).toHaveBeenCalledWith( + component.autofillOptionsForm.controls.uris.controls, + 1, + 2, + ); + expect(liveAnnouncer.announce).toHaveBeenCalledWith( + "reorderFieldDown websiteUri 3 3", + "assertive", + ); + }); + }); }); diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts index c3b2a0fb9f9..5b1e4eca103 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { LiveAnnouncer } from "@angular/cdk/a11y"; +import { CdkDragDrop, DragDropModule, moveItemInArray } from "@angular/cdk/drag-drop"; import { AsyncPipe, NgForOf, NgIf } from "@angular/common"; import { Component, OnInit, QueryList, ViewChildren } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @@ -41,6 +42,7 @@ interface UriField { templateUrl: "./autofill-options.component.html", standalone: true, imports: [ + DragDropModule, SectionComponent, SectionHeaderComponent, TypographyModule, @@ -229,4 +231,58 @@ export class AutofillOptionsComponent implements OnInit { removeUri(i: number) { this.autofillOptionsForm.controls.uris.removeAt(i); } + + /** Create a new list of LoginUriViews from the form objects and update the cipher */ + private updateUriFields() { + this.cipherFormContainer.patchCipher((cipher) => { + cipher.login.uris = this.uriControls.map( + (control) => + Object.assign(new LoginUriView(), { + uri: control.value.uri, + matchDetection: control.value.matchDetection ?? null, + }) as LoginUriView, + ); + return cipher; + }); + } + + /** Reorder the controls to match the new order after a "drop" event */ + onUriItemDrop(event: CdkDragDrop) { + moveItemInArray(this.uriControls, event.previousIndex, event.currentIndex); + this.updateUriFields(); + } + + /** Handles a uri item keyboard up or down event */ + async onUriItemKeydown(event: KeyboardEvent, index: number) { + if (event.key === "ArrowUp" && index !== 0) { + await this.reorderUriItems(event, index, "Up"); + } + + if (event.key === "ArrowDown" && index !== this.uriControls.length - 1) { + await this.reorderUriItems(event, index, "Down"); + } + } + + /** Reorders the uri items from a keyboard up or down event */ + async reorderUriItems(event: KeyboardEvent, previousIndex: number, direction: "Up" | "Down") { + const currentIndex = previousIndex + (direction === "Up" ? -1 : 1); + event.preventDefault(); + await this.liveAnnouncer.announce( + this.i18nService.t( + `reorderField${direction}`, + this.i18nService.t("websiteUri"), + currentIndex + 1, + this.uriControls.length, + ), + "assertive", + ); + moveItemInArray(this.uriControls, previousIndex, currentIndex); + this.updateUriFields(); + // Refocus the button after the reorder + // Angular re-renders the list when moving an item up which causes the focus to be lost + // Wait for the next tick to ensure the button is rendered before focusing + requestAnimationFrame(() => { + (event.target as HTMLButtonElement).focus(); + }); + } } diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html index a55716083de..5301e4f32b9 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html @@ -1,35 +1,50 @@ - - {{ uriLabel }} - - - - - - - {{ "matchDetection" | i18n }} - - - - +
    +
    + + {{ uriLabel }} + + + + +
    + +
    +
    + + {{ "matchDetection" | i18n }} + + + + +
    diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts index f712e3114e0..07bf7bef775 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { DragDropModule } from "@angular/cdk/drag-drop"; import { NgForOf, NgIf } from "@angular/common"; import { Component, @@ -43,6 +44,7 @@ import { }, ], imports: [ + DragDropModule, FormFieldModule, ReactiveFormsModule, IconButtonModule, @@ -74,6 +76,12 @@ export class UriOptionComponent implements ControlValueAccessor { { label: this.i18nService.t("never"), value: UriMatchStrategy.Never }, ]; + /** + * Whether the option can be reordered. If false, the reorder button will be hidden. + */ + @Input({ required: true }) + canReorder: boolean; + /** * Whether the URI can be removed from the form. If false, the remove button will be hidden. */ @@ -101,6 +109,9 @@ export class UriOptionComponent implements ControlValueAccessor { */ @Input({ required: true }) index: number; + @Output() + onKeydown = new EventEmitter(); + /** * Emits when the remove button is clicked and URI should be removed from the form. */ @@ -132,6 +143,10 @@ export class UriOptionComponent implements ControlValueAccessor { private onChange: any = () => {}; private onTouched: any = () => {}; + protected handleKeydown(event: KeyboardEvent) { + this.onKeydown.emit(event); + } + constructor( private formBuilder: FormBuilder, private i18nService: I18nService, diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html index fab3c8f1ab1..c7c5f4a930e 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html @@ -87,7 +87,7 @@ (click)="openAddEditCustomFieldDialog({ index: i, label: field.value.name })" [appA11yTitle]="'editFieldLabel' | i18n: field.value.name" bitIconButton="bwi-pencil-square" - class="tw-self-end" + class="tw-self-center tw-mt-2" data-testid="edit-custom-field-button" *ngIf="!isPartialEdit" > @@ -95,7 +95,7 @@ + diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts index 773ddd4ad66..500bb886f7a 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts @@ -7,7 +7,8 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ClientType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; @@ -22,6 +23,7 @@ import { } from "@bitwarden/components"; import { generate_ssh_key } from "@bitwarden/sdk-internal"; +import { SshImportPromptService } from "../../../services/ssh-import-prompt.service"; import { CipherFormContainer } from "../../cipher-form-container"; @Component({ @@ -60,11 +62,14 @@ export class SshKeySectionComponent implements OnInit { keyFingerprint: [""], }); + showImport = false; + constructor( private cipherFormContainer: CipherFormContainer, private formBuilder: FormBuilder, - private i18nService: I18nService, private sdkService: SdkService, + private sshImportPromptService: SshImportPromptService, + private platformUtilsService: PlatformUtilsService, ) { this.cipherFormContainer.registerChildForm("sshKeyDetails", this.sshKeyForm); this.sshKeyForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { @@ -87,6 +92,11 @@ export class SshKeySectionComponent implements OnInit { } this.sshKeyForm.disable(); + + // Web does not support clipboard access + if (this.platformUtilsService.getClientType() !== ClientType.Web) { + this.showImport = true; + } } /** Set form initial form values from the current cipher */ @@ -100,6 +110,17 @@ export class SshKeySectionComponent implements OnInit { }); } + async importSshKeyFromClipboard() { + const key = await this.sshImportPromptService.importSshKeyFromClipboard(); + if (key != null) { + this.sshKeyForm.setValue({ + privateKey: key.privateKey, + publicKey: key.publicKey, + keyFingerprint: key.keyFingerprint, + }); + } + } + private async generateSshKey() { await firstValueFrom(this.sdkService.client$); const sshKey = generate_ssh_key("Ed25519"); diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 3782b866f1d..1df96656da5 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -171,6 +171,10 @@ export class CipherViewComponent implements OnChanges, OnDestroy { } async checkPendingChangePasswordTasks(userId: UserId): Promise { + if (!(await firstValueFrom(this.isSecurityTasksEnabled$))) { + return; + } + const tasks = await firstValueFrom(this.defaultTaskService.pendingTasks$(userId)); this.hadPendingChangePasswordTask = tasks?.some((task) => { diff --git a/libs/vault/src/cipher-view/item-history/item-history-v2.component.html b/libs/vault/src/cipher-view/item-history/item-history-v2.component.html index 19d1cfe1744..c069e36dde1 100644 --- a/libs/vault/src/cipher-view/item-history/item-history-v2.component.html +++ b/libs/vault/src/cipher-view/item-history/item-history-v2.component.html @@ -3,12 +3,12 @@

    {{ "itemHistory" | i18n }}

    -

    +

    {{ "lastEdited" | i18n }}: {{ cipher.revisionDate | date: "medium" }}

    {{ "datePasswordUpdated" | i18n }}: diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index e4857411d05..f359b7289ae 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -25,8 +25,11 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon export * from "./components/carousel"; export * as VaultIcons from "./icons"; - export * from "./tasks"; +export * from "./notifications"; + +export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; +export { SshImportPromptService } from "./services/ssh-import-prompt.service"; export * from "./abstractions/change-login-password.service"; export * from "./services/default-change-login-password.service"; diff --git a/libs/vault/src/notifications/abstractions/end-user-notification.service.ts b/libs/vault/src/notifications/abstractions/end-user-notification.service.ts new file mode 100644 index 00000000000..2ed7e1de631 --- /dev/null +++ b/libs/vault/src/notifications/abstractions/end-user-notification.service.ts @@ -0,0 +1,49 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { NotificationView } from "../models"; + +/** + * A service for retrieving and managing notifications for end users. + */ +export abstract class EndUserNotificationService { + /** + * Observable of all notifications for the given user. + * @param userId + */ + abstract notifications$(userId: UserId): Observable; + + /** + * Observable of all unread notifications for the given user. + * @param userId + */ + abstract unreadNotifications$(userId: UserId): Observable; + + /** + * Mark a notification as read. + * @param notificationId + * @param userId + */ + abstract markAsRead(notificationId: any, userId: UserId): Promise; + + /** + * Mark a notification as deleted. + * @param notificationId + * @param userId + */ + abstract markAsDeleted(notificationId: any, userId: UserId): Promise; + + /** + * Create/update a notification in the state for the user specified within the notification. + * @remarks This method should only be called when a notification payload is received from the web socket. + * @param notification + */ + abstract upsert(notification: Notification): Promise; + + /** + * Clear all notifications from state for the given user. + * @param userId + */ + abstract clearState(userId: UserId): Promise; +} diff --git a/libs/vault/src/notifications/index.ts b/libs/vault/src/notifications/index.ts new file mode 100644 index 00000000000..0c9d5c0d16b --- /dev/null +++ b/libs/vault/src/notifications/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions/end-user-notification.service"; +export * from "./services/default-end-user-notification.service"; diff --git a/libs/vault/src/notifications/models/index.ts b/libs/vault/src/notifications/models/index.ts new file mode 100644 index 00000000000..b782335caa9 --- /dev/null +++ b/libs/vault/src/notifications/models/index.ts @@ -0,0 +1,3 @@ +export * from "./notification-view"; +export * from "./notification-view.data"; +export * from "./notification-view.response"; diff --git a/libs/vault/src/notifications/models/notification-view.data.ts b/libs/vault/src/notifications/models/notification-view.data.ts new file mode 100644 index 00000000000..07c147052ad --- /dev/null +++ b/libs/vault/src/notifications/models/notification-view.data.ts @@ -0,0 +1,37 @@ +import { Jsonify } from "type-fest"; + +import { NotificationId } from "@bitwarden/common/types/guid"; + +import { NotificationViewResponse } from "./notification-view.response"; + +export class NotificationViewData { + id: NotificationId; + priority: number; + title: string; + body: string; + date: Date; + readDate: Date | null; + deletedDate: Date | null; + + constructor(response: NotificationViewResponse) { + this.id = response.id; + this.priority = response.priority; + this.title = response.title; + this.body = response.body; + this.date = response.date; + this.readDate = response.readDate; + this.deletedDate = response.deletedDate; + } + + static fromJSON(obj: Jsonify) { + return Object.assign(new NotificationViewData({} as NotificationViewResponse), obj, { + id: obj.id, + priority: obj.priority, + title: obj.title, + body: obj.body, + date: new Date(obj.date), + readDate: obj.readDate ? new Date(obj.readDate) : null, + deletedDate: obj.deletedDate ? new Date(obj.deletedDate) : null, + }); + } +} diff --git a/libs/vault/src/notifications/models/notification-view.response.ts b/libs/vault/src/notifications/models/notification-view.response.ts new file mode 100644 index 00000000000..bbebf25bd4e --- /dev/null +++ b/libs/vault/src/notifications/models/notification-view.response.ts @@ -0,0 +1,23 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { NotificationId } from "@bitwarden/common/types/guid"; + +export class NotificationViewResponse extends BaseResponse { + id: NotificationId; + priority: number; + title: string; + body: string; + date: Date; + readDate: Date; + deletedDate: Date; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.priority = this.getResponseProperty("Priority"); + this.title = this.getResponseProperty("Title"); + this.body = this.getResponseProperty("Body"); + this.date = this.getResponseProperty("Date"); + this.readDate = this.getResponseProperty("ReadDate"); + this.deletedDate = this.getResponseProperty("DeletedDate"); + } +} diff --git a/libs/vault/src/notifications/models/notification-view.ts b/libs/vault/src/notifications/models/notification-view.ts new file mode 100644 index 00000000000..b577a889d05 --- /dev/null +++ b/libs/vault/src/notifications/models/notification-view.ts @@ -0,0 +1,21 @@ +import { NotificationId } from "@bitwarden/common/types/guid"; + +export class NotificationView { + id: NotificationId; + priority: number; + title: string; + body: string; + date: Date; + readDate: Date | null; + deletedDate: Date | null; + + constructor(obj: any) { + this.id = obj.id; + this.priority = obj.priority; + this.title = obj.title; + this.body = obj.body; + this.date = obj.date; + this.readDate = obj.readDate; + this.deletedDate = obj.deletedDate; + } +} diff --git a/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts b/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts new file mode 100644 index 00000000000..ac4304998bc --- /dev/null +++ b/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts @@ -0,0 +1,193 @@ +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { NotificationId, UserId } from "@bitwarden/common/types/guid"; +import { DefaultEndUserNotificationService } from "@bitwarden/vault"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../common/spec"; +import { NotificationViewResponse } from "../models"; +import { NOTIFICATIONS } from "../state/end-user-notification.state"; + +describe("End User Notification Center Service", () => { + let fakeStateProvider: FakeStateProvider; + + const mockApiSend = jest.fn(); + + let testBed: TestBed; + + beforeEach(async () => { + mockApiSend.mockClear(); + + fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); + + testBed = TestBed.configureTestingModule({ + imports: [], + providers: [ + DefaultEndUserNotificationService, + { + provide: StateProvider, + useValue: fakeStateProvider, + }, + { + provide: ApiService, + useValue: { + send: mockApiSend, + }, + }, + ], + }); + }); + + describe("notifications$", () => { + it("should return notifications from state when not null", async () => { + fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ + { + id: "notification-id" as NotificationId, + } as NotificationViewResponse, + ]); + + const { notifications$ } = testBed.inject(DefaultEndUserNotificationService); + + const result = await firstValueFrom(notifications$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiSend).not.toHaveBeenCalled(); + }); + + it("should return notifications API when state is null", async () => { + mockApiSend.mockResolvedValue({ + data: [ + { + id: "notification-id", + }, + ] as NotificationViewResponse[], + }); + + fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any); + + const { notifications$ } = testBed.inject(DefaultEndUserNotificationService); + + const result = await firstValueFrom(notifications$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true); + }); + + it("should share the same observable for the same user", async () => { + const { notifications$ } = testBed.inject(DefaultEndUserNotificationService); + + const first = notifications$("user-id" as UserId); + const second = notifications$("user-id" as UserId); + + expect(first).toBe(second); + }); + }); + + describe("unreadNotifications$", () => { + it("should return unread notifications from state when read value is null", async () => { + fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ + { + id: "notification-id" as NotificationId, + readDate: null as any, + } as NotificationViewResponse, + ]); + + const { unreadNotifications$ } = testBed.inject(DefaultEndUserNotificationService); + + const result = await firstValueFrom(unreadNotifications$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiSend).not.toHaveBeenCalled(); + }); + }); + + describe("getNotifications", () => { + it("should call getNotifications returning notifications from API", async () => { + mockApiSend.mockResolvedValue({ + data: [ + { + id: "notification-id", + }, + ] as NotificationViewResponse[], + }); + const service = testBed.inject(DefaultEndUserNotificationService); + + await service.getNotifications("user-id" as UserId); + + expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true); + }); + }); + it("should update local state when notifications are updated", async () => { + mockApiSend.mockResolvedValue({ + data: [ + { + id: "notification-id", + }, + ] as NotificationViewResponse[], + }); + + const mock = fakeStateProvider.singleUser.mockFor( + "user-id" as UserId, + NOTIFICATIONS, + null as any, + ); + + const service = testBed.inject(DefaultEndUserNotificationService); + + await service.getNotifications("user-id" as UserId); + + expect(mock.nextMock).toHaveBeenCalledWith([ + expect.objectContaining({ + id: "notification-id" as NotificationId, + } as NotificationViewResponse), + ]); + }); + + describe("clear", () => { + it("should clear the local notification state for the user", async () => { + const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ + { + id: "notification-id" as NotificationId, + } as NotificationViewResponse, + ]); + + const service = testBed.inject(DefaultEndUserNotificationService); + + await service.clearState("user-id" as UserId); + + expect(mock.nextMock).toHaveBeenCalledWith([]); + }); + }); + + describe("markAsDeleted", () => { + it("should send an API request to mark the notification as deleted", async () => { + const service = testBed.inject(DefaultEndUserNotificationService); + + await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId); + expect(mockApiSend).toHaveBeenCalledWith( + "DELETE", + "/notifications/notification-id/delete", + null, + true, + false, + ); + }); + }); + + describe("markAsRead", () => { + it("should send an API request to mark the notification as read", async () => { + const service = testBed.inject(DefaultEndUserNotificationService); + + await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId); + expect(mockApiSend).toHaveBeenCalledWith( + "PATCH", + "/notifications/notification-id/read", + null, + true, + false, + ); + }); + }); +}); diff --git a/libs/vault/src/notifications/services/default-end-user-notification.service.ts b/libs/vault/src/notifications/services/default-end-user-notification.service.ts new file mode 100644 index 00000000000..517a968f8af --- /dev/null +++ b/libs/vault/src/notifications/services/default-end-user-notification.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from "@angular/core"; +import { map, Observable, switchMap } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities"; +import { EndUserNotificationService } from "../abstractions/end-user-notification.service"; +import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models"; +import { NOTIFICATIONS } from "../state/end-user-notification.state"; + +/** + * A service for retrieving and managing notifications for end users. + */ +@Injectable() +export class DefaultEndUserNotificationService implements EndUserNotificationService { + constructor( + private stateProvider: StateProvider, + private apiService: ApiService, + ) {} + + notifications$ = perUserCache$((userId: UserId): Observable => { + return this.notificationState(userId).state$.pipe( + switchMap(async (notifications) => { + if (notifications == null) { + await this.fetchNotificationsFromApi(userId); + } + return notifications; + }), + filterOutNullish(), + map((notifications) => + notifications.map((notification) => new NotificationView(notification)), + ), + ); + }); + + unreadNotifications$ = perUserCache$((userId: UserId): Observable => { + return this.notifications$(userId).pipe( + map((notifications) => notifications.filter((notification) => notification.readDate == null)), + ); + }); + + async markAsRead(notificationId: any, userId: UserId): Promise { + await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false); + await this.getNotifications(userId); + } + + async markAsDeleted(notificationId: any, userId: UserId): Promise { + await this.apiService.send( + "DELETE", + `/notifications/${notificationId}/delete`, + null, + true, + false, + ); + await this.getNotifications(userId); + } + + upsert(notification: Notification): any {} + + async clearState(userId: UserId): Promise { + await this.updateNotificationState(userId, []); + } + + async getNotifications(userId: UserId) { + await this.fetchNotificationsFromApi(userId); + } + + /** + * Fetches the notifications from the API and updates the local state + * @param userId + * @private + */ + private async fetchNotificationsFromApi(userId: UserId): Promise { + const res = await this.apiService.send("GET", "/notifications", null, true, true); + const response = new ListResponse(res, NotificationViewResponse); + const notificationData = response.data.map((n) => new NotificationView(n)); + await this.updateNotificationState(userId, notificationData); + } + + /** + * Updates the local state with notifications and returns the updated state + * @param userId + * @param notifications + * @private + */ + private updateNotificationState( + userId: UserId, + notifications: NotificationViewData[], + ): Promise { + return this.notificationState(userId).update(() => notifications); + } + + /** + * Returns the local state for notifications + * @param userId + * @private + */ + private notificationState(userId: UserId) { + return this.stateProvider.getUser(userId, NOTIFICATIONS); + } +} diff --git a/libs/vault/src/notifications/state/end-user-notification.state.ts b/libs/vault/src/notifications/state/end-user-notification.state.ts new file mode 100644 index 00000000000..644c8e42429 --- /dev/null +++ b/libs/vault/src/notifications/state/end-user-notification.state.ts @@ -0,0 +1,15 @@ +import { Jsonify } from "type-fest"; + +import { NOTIFICATION_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { NotificationViewData } from "../models"; + +export const NOTIFICATIONS = UserKeyDefinition.array( + NOTIFICATION_DISK, + "notifications", + { + deserializer: (notification: Jsonify) => + NotificationViewData.fromJSON(notification), + clearOn: ["logout", "lock"], + }, +); diff --git a/libs/vault/src/services/default-ssh-import-prompt.service.ts b/libs/vault/src/services/default-ssh-import-prompt.service.ts new file mode 100644 index 00000000000..c4e51dd3638 --- /dev/null +++ b/libs/vault/src/services/default-ssh-import-prompt.service.ts @@ -0,0 +1,109 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api"; +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui"; +import { import_ssh_key, SshKeyImportError, SshKeyView } from "@bitwarden/sdk-internal"; + +import { SshImportPromptService } from "./ssh-import-prompt.service"; + +/** + * Used to import ssh keys and prompt for their password. + */ +@Injectable() +export class DefaultSshImportPromptService implements SshImportPromptService { + constructor( + private dialogService: DialogService, + private toastService: ToastService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + ) {} + + async importSshKeyFromClipboard(): Promise { + const key = await this.platformUtilsService.readFromClipboard(); + + let isPasswordProtectedSshKey = false; + + let parsedKey: SshKeyView | null = null; + + try { + parsedKey = import_ssh_key(key); + } catch (e) { + const error = e as SshKeyImportError; + if (error.variant === "PasswordRequired" || error.variant === "WrongPassword") { + isPasswordProtectedSshKey = true; + } else { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)), + }); + return null; + } + } + + if (isPasswordProtectedSshKey) { + for (;;) { + const password = await this.getSshKeyPassword(); + if (password === "" || password == null) { + return null; + } + + try { + parsedKey = import_ssh_key(key, password); + break; + } catch (e) { + const error = e as SshKeyImportError; + if (error.variant !== "WrongPassword") { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)), + }); + return null; + } + } + } + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyImported"), + }); + + return new SshKeyData( + new SshKeyApi({ + privateKey: parsedKey!.privateKey, + publicKey: parsedKey!.publicKey, + keyFingerprint: parsedKey!.fingerprint, + }), + ); + } + + private sshImportErrorVariantToI18nKey(variant: string): string { + switch (variant) { + case "ParsingError": + return "invalidSshKey"; + case "UnsupportedKeyType": + return "sshKeyTypeUnsupported"; + case "PasswordRequired": + case "WrongPassword": + return "sshKeyWrongPassword"; + default: + return "errorOccurred"; + } + } + + private async getSshKeyPassword(): Promise { + const dialog = this.dialogService.open(SshKeyPasswordPromptComponent, { + ariaModal: true, + }); + + return await firstValueFrom(dialog.closed); + } +} diff --git a/libs/vault/src/services/ssh-import-prompt.service.spec.ts b/libs/vault/src/services/ssh-import-prompt.service.spec.ts new file mode 100644 index 00000000000..49b2b898d7a --- /dev/null +++ b/libs/vault/src/services/ssh-import-prompt.service.spec.ts @@ -0,0 +1,111 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api"; +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; +import { DialogService, ToastService } from "@bitwarden/components"; +import * as sdkInternal from "@bitwarden/sdk-internal"; + +import { DefaultSshImportPromptService } from "./default-ssh-import-prompt.service"; + +jest.mock("@bitwarden/sdk-internal"); + +const exampleSshKey = { + privateKey: "private_key", + publicKey: "public_key", + fingerprint: "key_fingerprint", +} as sdkInternal.SshKeyView; + +const exampleSshKeyData = new SshKeyData( + new SshKeyApi({ + publicKey: exampleSshKey.publicKey, + privateKey: exampleSshKey.privateKey, + keyFingerprint: exampleSshKey.fingerprint, + }), +); + +describe("SshImportPromptService", () => { + let sshImportPromptService: DefaultSshImportPromptService; + + let dialogService: MockProxy; + let toastService: MockProxy; + let platformUtilsService: MockProxy; + let i18nService: MockProxy; + + beforeEach(() => { + dialogService = mock(); + toastService = mock(); + platformUtilsService = mock(); + i18nService = mock(); + + sshImportPromptService = new DefaultSshImportPromptService( + dialogService, + toastService, + platformUtilsService, + i18nService, + ); + jest.clearAllMocks(); + }); + + describe("importSshKeyFromClipboard()", () => { + it("imports unencrypted ssh key", async () => { + jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(exampleSshKey); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData); + }); + + it("requests password for encrypted ssh key", async () => { + jest + .spyOn(sdkInternal, "import_ssh_key") + .mockImplementationOnce(() => { + throw { variant: "PasswordRequired" }; + }) + .mockImplementationOnce(() => exampleSshKey); + dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData); + expect(dialogService.open).toHaveBeenCalled(); + }); + + it("cancels when no password was provided", async () => { + jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => { + throw { variant: "PasswordRequired" }; + }); + dialogService.open.mockReturnValue({ closed: new BehaviorSubject("") } as any); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null); + expect(dialogService.open).toHaveBeenCalled(); + }); + + it("passes through error on no password", async () => { + jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => { + throw { variant: "UnsupportedKeyType" }; + }); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null); + expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported"); + }); + + it("passes through error with password", async () => { + jest + .spyOn(sdkInternal, "import_ssh_key") + .mockClear() + .mockImplementationOnce(() => { + throw { variant: "PasswordRequired" }; + }) + .mockImplementationOnce(() => { + throw { variant: "UnsupportedKeyType" }; + }); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null); + expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported"); + }); + }); +}); diff --git a/libs/vault/src/services/ssh-import-prompt.service.ts b/libs/vault/src/services/ssh-import-prompt.service.ts new file mode 100644 index 00000000000..aae5159895b --- /dev/null +++ b/libs/vault/src/services/ssh-import-prompt.service.ts @@ -0,0 +1,5 @@ +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; + +export abstract class SshImportPromptService { + abstract importSshKeyFromClipboard: () => Promise; +} diff --git a/libs/vault/tsconfig.json b/libs/vault/tsconfig.json index e1515183f22..6039dccd811 100644 --- a/libs/vault/tsconfig.json +++ b/libs/vault/tsconfig.json @@ -8,11 +8,13 @@ "@bitwarden/auth/common": ["../auth/src/common"], "@bitwarden/common/*": ["../common/src/*"], "@bitwarden/components": ["../components/src"], + "@bitwarden/importer-ui": ["../importer/src/components"], "@bitwarden/generator-components": ["../tools/generator/components/src"], "@bitwarden/generator-core": ["../tools/generator/core/src"], "@bitwarden/generator-history": ["../tools/generator/extensions/history/src"], "@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"], "@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"], + "@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"], "@bitwarden/key-management": ["../key-management/src"], "@bitwarden/platform": ["../platform/src"], "@bitwarden/ui-common": ["../ui/common/src"], diff --git a/package-lock.json b/package-lock.json index 7cb1d50947a..023b36afadc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,7 +96,7 @@ "@storybook/theming": "8.5.2", "@storybook/web-components-webpack5": "8.5.2", "@types/argon2-browser": "1.18.4", - "@types/chrome": "0.0.280", + "@types/chrome": "0.0.306", "@types/firefox-webext-browser": "120.0.4", "@types/inquirer": "8.2.10", "@types/jest": "29.5.12", @@ -10904,9 +10904,9 @@ } }, "node_modules/@types/chrome": { - "version": "0.0.280", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.280.tgz", - "integrity": "sha512-AotSmZrL9bcZDDmSI1D9dE7PGbhOur5L0cKxXd7IqbVizQWCY4gcvupPUVsQ4FfDj3V2tt/iOpomT9EY0s+w1g==", + "version": "0.0.306", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.306.tgz", + "integrity": "sha512-95kgcqvTNcaZCXmx/kIKY6uo83IaRNT3cuPxYqlB2Iu+HzKDCP4t7TUe7KhJijTdibcvn+SzziIcfSLIlgRnhQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a421d87b5de..571ec82fcda 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@storybook/theming": "8.5.2", "@storybook/web-components-webpack5": "8.5.2", "@types/argon2-browser": "1.18.4", - "@types/chrome": "0.0.280", + "@types/chrome": "0.0.306", "@types/firefox-webext-browser": "120.0.4", "@types/inquirer": "8.2.10", "@types/jest": "29.5.12",