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