mirror of
https://github.com/bitwarden/browser
synced 2026-01-21 20:03:43 +00:00
[PM-25693] - hide import sshKey button for view-only users (#17985)
* hide import sshKey button for view-only users * use @if * add optional chain * use computed property. update tests * move comment down
This commit is contained in:
@@ -15,15 +15,16 @@
|
||||
data-testid="toggle-privateKey-visibility"
|
||||
bitPasswordInputToggle
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-import"
|
||||
bitSuffix
|
||||
data-testid="import-privateKey"
|
||||
*ngIf="showImport"
|
||||
label="{{ 'importSshKeyFromClipboard' | i18n }}"
|
||||
(click)="importSshKeyFromClipboard()"
|
||||
></button>
|
||||
@if (showImport()) {
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-import"
|
||||
bitSuffix
|
||||
data-testid="import-privateKey"
|
||||
label="{{ 'importSshKeyFromClipboard' | i18n }}"
|
||||
(click)="importSshKeyFromClipboard()"
|
||||
></button>
|
||||
}
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
|
||||
import { generate_ssh_key } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SshImportPromptService } from "../../../services/ssh-import-prompt.service";
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
|
||||
import { SshKeySectionComponent } from "./sshkey-section.component";
|
||||
|
||||
jest.mock("@bitwarden/sdk-internal", () => {
|
||||
return {
|
||||
generate_ssh_key: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("SshKeySectionComponent", () => {
|
||||
let fixture: ComponentFixture<SshKeySectionComponent>;
|
||||
let component: SshKeySectionComponent;
|
||||
const mockI18nService = mock<I18nService>();
|
||||
|
||||
let formStatusChange$: Subject<string>;
|
||||
|
||||
let cipherFormContainer: {
|
||||
registerChildForm: jest.Mock;
|
||||
patchCipher: jest.Mock;
|
||||
getInitialCipherView: jest.Mock;
|
||||
formStatusChange$: Subject<string>;
|
||||
};
|
||||
|
||||
let sdkClient$: BehaviorSubject<unknown>;
|
||||
let sdkService: { client$: BehaviorSubject<unknown> };
|
||||
|
||||
let sshImportPromptService: { importSshKeyFromClipboard: jest.Mock };
|
||||
|
||||
let platformUtilsService: { getClientType: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
formStatusChange$ = new Subject<string>();
|
||||
|
||||
cipherFormContainer = {
|
||||
registerChildForm: jest.fn(),
|
||||
patchCipher: jest.fn(),
|
||||
getInitialCipherView: jest.fn(),
|
||||
formStatusChange$,
|
||||
};
|
||||
|
||||
sdkClient$ = new BehaviorSubject<unknown>({});
|
||||
sdkService = { client$: sdkClient$ };
|
||||
|
||||
sshImportPromptService = {
|
||||
importSshKeyFromClipboard: jest.fn(),
|
||||
};
|
||||
|
||||
platformUtilsService = {
|
||||
getClientType: jest.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SshKeySectionComponent],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: CipherFormContainer, useValue: cipherFormContainer },
|
||||
{ provide: SdkService, useValue: sdkService },
|
||||
{ provide: SshImportPromptService, useValue: sshImportPromptService },
|
||||
{ provide: PlatformUtilsService, useValue: platformUtilsService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SshKeySectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
// minimal required inputs
|
||||
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null });
|
||||
fixture.componentRef.setInput("disabled", false);
|
||||
|
||||
(generate_ssh_key as unknown as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
it("registers the sshKeyDetails form with the container in the constructor", () => {
|
||||
expect(cipherFormContainer.registerChildForm).toHaveBeenCalledTimes(1);
|
||||
expect(cipherFormContainer.registerChildForm).toHaveBeenCalledWith(
|
||||
"sshKeyDetails",
|
||||
component.sshKeyForm,
|
||||
);
|
||||
});
|
||||
|
||||
it("patches cipher sshKey whenever the form changes", () => {
|
||||
component.sshKeyForm.setValue({
|
||||
privateKey: "priv",
|
||||
publicKey: "pub",
|
||||
keyFingerprint: "fp",
|
||||
});
|
||||
|
||||
expect(cipherFormContainer.patchCipher).toHaveBeenCalledTimes(1);
|
||||
const patchFn = cipherFormContainer.patchCipher.mock.calls[0][0] as (c: any) => any;
|
||||
|
||||
const cipher: any = {};
|
||||
const patched = patchFn(cipher);
|
||||
|
||||
expect(patched.sshKey).toBeInstanceOf(SshKeyView);
|
||||
expect(patched.sshKey.privateKey).toBe("priv");
|
||||
expect(patched.sshKey.publicKey).toBe("pub");
|
||||
expect(patched.sshKey.keyFingerprint).toBe("fp");
|
||||
});
|
||||
|
||||
it("ngOnInit uses initial cipher sshKey (prefill) when present and does not generate", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(generate_ssh_key).not.toHaveBeenCalled();
|
||||
expect(component.sshKeyForm.get("privateKey")?.value).toBe("p1");
|
||||
expect(component.sshKeyForm.get("publicKey")?.value).toBe("p2");
|
||||
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("p3");
|
||||
});
|
||||
|
||||
it("ngOnInit falls back to originalCipherView sshKey when prefill is missing", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue(null);
|
||||
fixture.componentRef.setInput("originalCipherView", {
|
||||
edit: true,
|
||||
sshKey: { privateKey: "o1", publicKey: "o2", keyFingerprint: "o3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(generate_ssh_key).not.toHaveBeenCalled();
|
||||
expect(component.sshKeyForm.get("privateKey")?.value).toBe("o1");
|
||||
expect(component.sshKeyForm.get("publicKey")?.value).toBe("o2");
|
||||
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("o3");
|
||||
});
|
||||
|
||||
it("ngOnInit generates an ssh key when no sshKey exists and populates the form", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue(null);
|
||||
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null });
|
||||
|
||||
(generate_ssh_key as unknown as jest.Mock).mockReturnValue({
|
||||
privateKey: "genPriv",
|
||||
publicKey: "genPub",
|
||||
fingerprint: "genFp",
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(generate_ssh_key).toHaveBeenCalledTimes(1);
|
||||
expect(generate_ssh_key).toHaveBeenCalledWith("Ed25519");
|
||||
expect(component.sshKeyForm.get("privateKey")?.value).toBe("genPriv");
|
||||
expect(component.sshKeyForm.get("publicKey")?.value).toBe("genPub");
|
||||
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("genFp");
|
||||
});
|
||||
|
||||
it("ngOnInit disables the form", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.sshKeyForm.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sets showImport true when not Web and originalCipherView.edit is true", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.showImport()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps showImport false when client type is Web", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Web);
|
||||
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.showImport()).toBe(false);
|
||||
});
|
||||
|
||||
it("disables the ssh key form when formStatusChange emits enabled", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
component.sshKeyForm.enable();
|
||||
expect(component.sshKeyForm.disabled).toBe(false);
|
||||
|
||||
formStatusChange$.next("enabled");
|
||||
expect(component.sshKeyForm.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the import button only when showImport is true", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any);
|
||||
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const importBtn = fixture.debugElement.query(By.css('[data-testid="import-privateKey"]'));
|
||||
expect(importBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it("importSshKeyFromClipboard sets form values when a key is returned", async () => {
|
||||
sshImportPromptService.importSshKeyFromClipboard.mockResolvedValue({
|
||||
privateKey: "cPriv",
|
||||
publicKey: "cPub",
|
||||
keyFingerprint: "cFp",
|
||||
});
|
||||
|
||||
await component.importSshKeyFromClipboard();
|
||||
|
||||
expect(component.sshKeyForm.get("privateKey")?.value).toBe("cPriv");
|
||||
expect(component.sshKeyForm.get("publicKey")?.value).toBe("cPub");
|
||||
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("cFp");
|
||||
});
|
||||
|
||||
it("importSshKeyFromClipboard does nothing when null is returned", async () => {
|
||||
component.sshKeyForm.setValue({ privateKey: "a", publicKey: "b", keyFingerprint: "c" });
|
||||
sshImportPromptService.importSshKeyFromClipboard.mockResolvedValue(null);
|
||||
|
||||
await component.importSshKeyFromClipboard();
|
||||
|
||||
expect(component.sshKeyForm.get("privateKey")?.value).toBe("a");
|
||||
expect(component.sshKeyForm.get("publicKey")?.value).toBe("b");
|
||||
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("c");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, inject, Input, OnInit } from "@angular/core";
|
||||
import { Component, computed, DestroyRef, inject, input, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
@@ -43,15 +43,9 @@ import { CipherFormContainer } from "../../cipher-form-container";
|
||||
],
|
||||
})
|
||||
export class SshKeySectionComponent implements OnInit {
|
||||
/** The original cipher */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() originalCipherView: CipherView;
|
||||
readonly originalCipherView = input<CipherView | null>(null);
|
||||
|
||||
/** True when all fields should be disabled */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() disabled: boolean;
|
||||
readonly disabled = input(false);
|
||||
|
||||
/**
|
||||
* All form fields associated with the ssh key
|
||||
@@ -65,7 +59,14 @@ export class SshKeySectionComponent implements OnInit {
|
||||
keyFingerprint: [""],
|
||||
});
|
||||
|
||||
showImport = false;
|
||||
readonly showImport = computed(() => {
|
||||
return (
|
||||
// Web does not support clipboard access
|
||||
this.platformUtilsService.getClientType() !== ClientType.Web &&
|
||||
this.originalCipherView()?.edit
|
||||
);
|
||||
});
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
@@ -90,7 +91,7 @@ export class SshKeySectionComponent implements OnInit {
|
||||
|
||||
async ngOnInit() {
|
||||
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
||||
const sshKeyView = prefillCipher?.sshKey ?? this.originalCipherView?.sshKey;
|
||||
const sshKeyView = prefillCipher?.sshKey ?? this.originalCipherView()?.sshKey;
|
||||
|
||||
if (sshKeyView) {
|
||||
this.setInitialValues(sshKeyView);
|
||||
@@ -100,11 +101,6 @@ export class SshKeySectionComponent implements OnInit {
|
||||
|
||||
this.sshKeyForm.disable();
|
||||
|
||||
// Web does not support clipboard access
|
||||
if (this.platformUtilsService.getClientType() !== ClientType.Web) {
|
||||
this.showImport = true;
|
||||
}
|
||||
|
||||
// Disable the form if the cipher form container is enabled
|
||||
// to prevent user interaction
|
||||
this.cipherFormContainer.formStatusChange$
|
||||
|
||||
Reference in New Issue
Block a user