diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d23cfa58283..5ba84c1f195 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -119,6 +119,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/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 67b770230cd..127e07f25e8 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" }, @@ -4697,6 +4709,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 +5143,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/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/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/background/main.background.ts b/apps/browser/src/background/main.background.ts index cd65220936e..a5967b5fe76 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1012,6 +1012,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/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/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/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 0e776375e6a..555337736c7 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -780,6 +780,7 @@ export class ServiceContainer { this.encryptService, this.pinService, this.accountService, + this.sdkService, ); this.individualExportService = new IndividualVaultExportService( 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/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/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 }}
-
+
@@ -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 @@
  • 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..68900b2ed74 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": { @@ -9330,7 +9380,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 +9394,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 +9436,7 @@ } } }, - "smIntegrationTooltip":{ + "smIntegrationTooltip": { "message": "Set up $INTEGRATION$.", "placeholders": { "integration": { @@ -9386,7 +9445,7 @@ } } }, - "smSdkTooltip":{ + "smSdkTooltip": { "message": "View $SDK$ repository", "placeholders": { "sdk": { @@ -9395,7 +9454,7 @@ } } }, - "integrationCardAriaLabel":{ + "integrationCardAriaLabel": { "message": "open $INTEGRATION$ implementation guide in a new tab.", "placeholders": { "integration": { @@ -9404,7 +9463,7 @@ } } }, - "smSdkAriaLabel":{ + "smSdkAriaLabel": { "message": "view $SDK$ repository in a new tab.", "placeholders": { "sdk": { @@ -9413,7 +9472,7 @@ } } }, - "smIntegrationCardAriaLabel":{ + "smIntegrationCardAriaLabel": { "message": "set up $INTEGRATION$ implementation guide in a new tab.", "placeholders": { "integration": { @@ -9820,7 +9879,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 +10181,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 +10422,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 +10617,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/icons/browser-extension.ts b/libs/vault/src/icons/browser-extension.ts index f0f9b781491..ac54322292f 100644 --- a/libs/vault/src/icons/browser-extension.ts +++ b/libs/vault/src/icons/browser-extension.ts @@ -1,7 +1,7 @@ import { svgIcon } from "@bitwarden/components"; export const BrowserExtensionIcon = svgIcon` - + 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"],