mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
[PM-8524] Cipher Form - Edit Login Details Section (#10081)
* [PM-8524] Introduce login details section component
* [PM-8524] Add ability to remove passkey
* [PM-8524] Introduce TotpCaptureService and the Browser implementation
* [PM-8524] Tweak storybook
* [PM-8524] Add note regarding existing login view references
* [PM-8524] Fix clone mode so that a new cipher is created
* [PM-8524] Add support for generating usernames/passwords and auditing passwords
* [PM-8524] Migrate password/username generation to CipherFormGenerationService
* [PM-8524] Add optional passwordInput to BitPasswordInputToggle to support conditionally rendered password toggle buttons
* [PM-8524] Add LoginDetailsSection tests
* [PM-8524] Add BrowserTotpCaptureService tests
* Revert "[PM-8524] Add optional passwordInput to BitPasswordInputToggle to support conditionally rendered password toggle buttons"
This reverts commit e76a0ccfe8
.
* [PM-8524] Add null check to password input toggle
This commit is contained in:
parent
3bfa024b87
commit
f75c1ab02d
@ -639,6 +639,15 @@
|
|||||||
"totpCapture": {
|
"totpCapture": {
|
||||||
"message": "Scan authenticator QR code from current webpage"
|
"message": "Scan authenticator QR code from current webpage"
|
||||||
},
|
},
|
||||||
|
"totpHelperTitle": {
|
||||||
|
"message": "Make 2-step verification seamless"
|
||||||
|
},
|
||||||
|
"totpHelper": {
|
||||||
|
"message": "Bitwarden can store and fill 2-step verification codes. Copy and paste the key into this field."
|
||||||
|
},
|
||||||
|
"totpHelperWithCapture": {
|
||||||
|
"message": "Bitwarden can store and fill 2-step verification codes. Select the camera icon to take a screenshot of this website's authenticator QR code, or copy and paste the key into this field."
|
||||||
|
},
|
||||||
"copyTOTP": {
|
"copyTOTP": {
|
||||||
"message": "Copy Authenticator key (TOTP)"
|
"message": "Copy Authenticator key (TOTP)"
|
||||||
},
|
},
|
||||||
@ -3619,6 +3628,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"loginCredentials": {
|
||||||
|
"message": "Login credentials"
|
||||||
|
},
|
||||||
|
"authenticatorKey": {
|
||||||
|
"message": "Authenticator key"
|
||||||
|
},
|
||||||
"cardDetails": {
|
"cardDetails": {
|
||||||
"message": "Card details"
|
"message": "Card details"
|
||||||
},
|
},
|
||||||
|
@ -16,11 +16,13 @@ import {
|
|||||||
CipherFormMode,
|
CipherFormMode,
|
||||||
CipherFormModule,
|
CipherFormModule,
|
||||||
DefaultCipherFormConfigService,
|
DefaultCipherFormConfigService,
|
||||||
|
TotpCaptureService,
|
||||||
} from "@bitwarden/vault";
|
} from "@bitwarden/vault";
|
||||||
|
|
||||||
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
|
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
|
||||||
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
|
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
|
||||||
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
|
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
|
||||||
|
import { BrowserTotpCaptureService } from "../../../services/browser-totp-capture.service";
|
||||||
import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component";
|
import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -79,7 +81,10 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
|
|||||||
selector: "app-add-edit-v2",
|
selector: "app-add-edit-v2",
|
||||||
templateUrl: "add-edit-v2.component.html",
|
templateUrl: "add-edit-v2.component.html",
|
||||||
standalone: true,
|
standalone: true,
|
||||||
providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }],
|
providers: [
|
||||||
|
{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService },
|
||||||
|
{ provide: TotpCaptureService, useClass: BrowserTotpCaptureService },
|
||||||
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
@ -0,0 +1,69 @@
|
|||||||
|
import { TestBed } from "@angular/core/testing";
|
||||||
|
import qrcodeParser from "qrcode-parser";
|
||||||
|
|
||||||
|
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||||
|
|
||||||
|
import { BrowserTotpCaptureService } from "./browser-totp-capture.service";
|
||||||
|
|
||||||
|
jest.mock("qrcode-parser", () => jest.fn());
|
||||||
|
|
||||||
|
const mockQrcodeParser = qrcodeParser as jest.Mock;
|
||||||
|
|
||||||
|
describe("BrowserTotpCaptureService", () => {
|
||||||
|
let testBed: TestBed;
|
||||||
|
let service: BrowserTotpCaptureService;
|
||||||
|
let mockCaptureVisibleTab: jest.SpyInstance;
|
||||||
|
|
||||||
|
const validTotpUrl = "otpauth://totp/label?secret=123";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCaptureVisibleTab = jest.spyOn(BrowserApi, "captureVisibleTab");
|
||||||
|
mockCaptureVisibleTab.mockResolvedValue("screenshot");
|
||||||
|
|
||||||
|
testBed = TestBed.configureTestingModule({
|
||||||
|
providers: [BrowserTotpCaptureService],
|
||||||
|
});
|
||||||
|
service = testBed.inject(BrowserTotpCaptureService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be created", () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call captureVisibleTab and qrcodeParser when captureTotpSecret is called", async () => {
|
||||||
|
mockQrcodeParser.mockResolvedValue({ toString: () => validTotpUrl });
|
||||||
|
|
||||||
|
await service.captureTotpSecret();
|
||||||
|
|
||||||
|
expect(mockCaptureVisibleTab).toHaveBeenCalled();
|
||||||
|
expect(mockQrcodeParser).toHaveBeenCalledWith("screenshot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the totpUrl when captureTotpSecret is called", async () => {
|
||||||
|
mockQrcodeParser.mockResolvedValue({ toString: () => validTotpUrl });
|
||||||
|
|
||||||
|
const result = await service.captureTotpSecret();
|
||||||
|
|
||||||
|
expect(result).toEqual(validTotpUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when the URL is not the otpauth: protocol", async () => {
|
||||||
|
mockQrcodeParser.mockResolvedValue({ toString: () => "https://example.com" });
|
||||||
|
|
||||||
|
const result = await service.captureTotpSecret();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when the URL is missing the secret parameter", async () => {
|
||||||
|
mockQrcodeParser.mockResolvedValue({ toString: () => "otpauth://totp/label" });
|
||||||
|
|
||||||
|
const result = await service.captureTotpSecret();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable } from "@angular/core";
|
||||||
|
import qrcodeParser from "qrcode-parser";
|
||||||
|
|
||||||
|
import { TotpCaptureService } from "@bitwarden/vault";
|
||||||
|
|
||||||
|
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of TotpCaptureService for the browser which captures the
|
||||||
|
* TOTP secret from the currently visible tab.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BrowserTotpCaptureService implements TotpCaptureService {
|
||||||
|
async captureTotpSecret() {
|
||||||
|
const screenshot = await BrowserApi.captureVisibleTab();
|
||||||
|
const data = await qrcodeParser(screenshot);
|
||||||
|
const url = new URL(data.toString());
|
||||||
|
if (url.protocol === "otpauth:" && url.searchParams.has("secret")) {
|
||||||
|
return data.toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@ -42,6 +42,12 @@
|
|||||||
"cardholderName": {
|
"cardholderName": {
|
||||||
"message": "Cardholder name"
|
"message": "Cardholder name"
|
||||||
},
|
},
|
||||||
|
"loginCredentials": {
|
||||||
|
"message": "Login credentials"
|
||||||
|
},
|
||||||
|
"authenticatorKey": {
|
||||||
|
"message": "Authenticator key"
|
||||||
|
},
|
||||||
"number": {
|
"number": {
|
||||||
"message": "Number"
|
"message": "Number"
|
||||||
},
|
},
|
||||||
@ -138,6 +144,15 @@
|
|||||||
"authenticatorKeyTotp": {
|
"authenticatorKeyTotp": {
|
||||||
"message": "Authenticator key (TOTP)"
|
"message": "Authenticator key (TOTP)"
|
||||||
},
|
},
|
||||||
|
"totpHelperTitle": {
|
||||||
|
"message": "Make 2-step verification seamless"
|
||||||
|
},
|
||||||
|
"totpHelper": {
|
||||||
|
"message": "Bitwarden can store and fill 2-step verification codes. Copy and paste the key into this field."
|
||||||
|
},
|
||||||
|
"totpHelperWithCapture": {
|
||||||
|
"message": "Bitwarden can store and fill 2-step verification codes. Select the camera icon to take a screenshot of this website's authenticator QR code, or copy and paste the key into this field."
|
||||||
|
},
|
||||||
"folder": {
|
"folder": {
|
||||||
"message": "Folder"
|
"message": "Folder"
|
||||||
},
|
},
|
||||||
|
@ -56,7 +56,9 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngAfterContentInit(): void {
|
ngAfterContentInit(): void {
|
||||||
this.toggled = this.formField.input.type !== "password";
|
if (this.formField.input?.type) {
|
||||||
|
this.toggled = this.formField.input.type !== "password";
|
||||||
|
}
|
||||||
this.button.icon = this.icon;
|
this.button.icon = this.icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Service responsible for generating random passwords and usernames.
|
||||||
|
*/
|
||||||
|
export abstract class CipherFormGenerationService {
|
||||||
|
/**
|
||||||
|
* Generates a random password. Called when the user clicks the "Generate Password" button in the UI.
|
||||||
|
*/
|
||||||
|
abstract generatePassword(): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a random username. Called when the user clicks the "Generate Username" button in the UI.
|
||||||
|
*/
|
||||||
|
abstract generateUsername(): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an initial password for a new cipher. This should not involve any user interaction as it will
|
||||||
|
* be used to pre-fill the password field in the UI for new Login ciphers.
|
||||||
|
*/
|
||||||
|
abstract generateInitialPassword(): Promise<string | null>;
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Service to capture TOTP secret from a client application.
|
||||||
|
*/
|
||||||
|
export abstract class TotpCaptureService {
|
||||||
|
/**
|
||||||
|
* Captures a TOTP secret and returns it as a string. Returns null if no TOTP secret was found.
|
||||||
|
*/
|
||||||
|
abstract captureTotpSecret(): Promise<string | null>;
|
||||||
|
}
|
@ -6,6 +6,7 @@ import { CardDetailsSectionComponent } from "./components/card-details-section/c
|
|||||||
import { CustomFieldsComponent } from "./components/custom-fields/custom-fields.component";
|
import { CustomFieldsComponent } from "./components/custom-fields/custom-fields.component";
|
||||||
import { IdentitySectionComponent } from "./components/identity/identity.component";
|
import { IdentitySectionComponent } from "./components/identity/identity.component";
|
||||||
import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component";
|
import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component";
|
||||||
|
import { LoginDetailsSectionComponent } from "./components/login-details-section/login-details-section.component";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The complete form for a cipher. Includes all the sub-forms from their respective section components.
|
* The complete form for a cipher. Includes all the sub-forms from their respective section components.
|
||||||
@ -14,6 +15,7 @@ import { ItemDetailsSectionComponent } from "./components/item-details/item-deta
|
|||||||
export type CipherForm = {
|
export type CipherForm = {
|
||||||
itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"];
|
itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"];
|
||||||
additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"];
|
additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"];
|
||||||
|
loginDetails?: LoginDetailsSectionComponent["loginDetailsForm"];
|
||||||
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
|
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
|
||||||
identityDetails?: IdentitySectionComponent["identityForm"];
|
identityDetails?: IdentitySectionComponent["identityForm"];
|
||||||
customFields?: CustomFieldsComponent["customFieldsForm"];
|
customFields?: CustomFieldsComponent["customFieldsForm"];
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
|
|
||||||
|
import { CipherFormGenerationService } from "./abstractions/cipher-form-generation.service";
|
||||||
import { CipherFormService } from "./abstractions/cipher-form.service";
|
import { CipherFormService } from "./abstractions/cipher-form.service";
|
||||||
import { CipherFormComponent } from "./components/cipher-form.component";
|
import { CipherFormComponent } from "./components/cipher-form.component";
|
||||||
|
import { DefaultCipherFormGenerationService } from "./services/default-cipher-form-generation.service";
|
||||||
import { DefaultCipherFormService } from "./services/default-cipher-form.service";
|
import { DefaultCipherFormService } from "./services/default-cipher-form.service";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@ -11,6 +13,10 @@ import { DefaultCipherFormService } from "./services/default-cipher-form.service
|
|||||||
provide: CipherFormService,
|
provide: CipherFormService,
|
||||||
useClass: DefaultCipherFormService,
|
useClass: DefaultCipherFormService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: CipherFormGenerationService,
|
||||||
|
useClass: DefaultCipherFormGenerationService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
exports: [CipherFormComponent],
|
exports: [CipherFormComponent],
|
||||||
})
|
})
|
||||||
|
@ -9,17 +9,24 @@ import {
|
|||||||
} from "@storybook/angular";
|
} from "@storybook/angular";
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||||
|
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||||
import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components";
|
import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components";
|
||||||
import { CipherFormConfig, PasswordRepromptService } from "@bitwarden/vault";
|
import {
|
||||||
|
CipherFormConfig,
|
||||||
|
CipherFormGenerationService,
|
||||||
|
PasswordRepromptService,
|
||||||
|
} from "@bitwarden/vault";
|
||||||
import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests";
|
import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests";
|
||||||
|
|
||||||
import { CipherFormService } from "./abstractions/cipher-form.service";
|
import { CipherFormService } from "./abstractions/cipher-form.service";
|
||||||
|
import { TotpCaptureService } from "./abstractions/totp-capture.service";
|
||||||
import { CipherFormModule } from "./cipher-form.module";
|
import { CipherFormModule } from "./cipher-form.module";
|
||||||
import { CipherFormComponent } from "./components/cipher-form.component";
|
import { CipherFormComponent } from "./components/cipher-form.component";
|
||||||
|
|
||||||
@ -73,6 +80,17 @@ const defaultConfig: CipherFormConfig = {
|
|||||||
collectionIds: ["col1"],
|
collectionIds: ["col1"],
|
||||||
favorite: false,
|
favorite: false,
|
||||||
notes: "Example notes",
|
notes: "Example notes",
|
||||||
|
viewPassword: true,
|
||||||
|
login: Object.assign(new LoginView(), {
|
||||||
|
username: "testuser",
|
||||||
|
password: "testpassword",
|
||||||
|
fido2Credentials: [
|
||||||
|
{
|
||||||
|
creationDate: new Date(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totp: "123456",
|
||||||
|
}) as LoginView,
|
||||||
} as unknown as Cipher,
|
} as unknown as Cipher,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -113,6 +131,26 @@ export default {
|
|||||||
enabled$: new BehaviorSubject(true),
|
enabled$: new BehaviorSubject(true),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: CipherFormGenerationService,
|
||||||
|
useValue: {
|
||||||
|
generateInitialPassword: () => Promise.resolve("initial-password"),
|
||||||
|
generatePassword: () => Promise.resolve("random-password"),
|
||||||
|
generateUsername: () => Promise.resolve("random-username"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TotpCaptureService,
|
||||||
|
useValue: {
|
||||||
|
captureTotpSecret: () => Promise.resolve("some-value"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: AuditService,
|
||||||
|
useValue: {
|
||||||
|
passwordLeaked: () => Promise.resolve(0),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
componentWrapperDecorator(
|
componentWrapperDecorator(
|
||||||
|
@ -6,6 +6,10 @@
|
|||||||
[originalCipherView]="originalCipherView"
|
[originalCipherView]="originalCipherView"
|
||||||
></vault-item-details-section>
|
></vault-item-details-section>
|
||||||
|
|
||||||
|
<vault-login-details-section
|
||||||
|
*ngIf="config.cipherType === CipherType.Login"
|
||||||
|
></vault-login-details-section>
|
||||||
|
|
||||||
<vault-identity-section
|
<vault-identity-section
|
||||||
*ngIf="config.cipherType === CipherType.Identity"
|
*ngIf="config.cipherType === CipherType.Identity"
|
||||||
[disabled]="config.mode === 'partial-edit'"
|
[disabled]="config.mode === 'partial-edit'"
|
||||||
|
@ -39,6 +39,7 @@ import { AdditionalOptionsSectionComponent } from "./additional-options/addition
|
|||||||
import { CardDetailsSectionComponent } from "./card-details-section/card-details-section.component";
|
import { CardDetailsSectionComponent } from "./card-details-section/card-details-section.component";
|
||||||
import { IdentitySectionComponent } from "./identity/identity.component";
|
import { IdentitySectionComponent } from "./identity/identity.component";
|
||||||
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
|
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
|
||||||
|
import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "vault-cipher-form",
|
selector: "vault-cipher-form",
|
||||||
@ -64,6 +65,7 @@ import { ItemDetailsSectionComponent } from "./item-details/item-details-section
|
|||||||
IdentitySectionComponent,
|
IdentitySectionComponent,
|
||||||
NgIf,
|
NgIf,
|
||||||
AdditionalOptionsSectionComponent,
|
AdditionalOptionsSectionComponent,
|
||||||
|
LoginDetailsSectionComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer {
|
export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer {
|
||||||
@ -184,6 +186,10 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.updatedCipherView = Object.assign(this.updatedCipherView, this.originalCipherView);
|
this.updatedCipherView = Object.assign(this.updatedCipherView, this.originalCipherView);
|
||||||
|
|
||||||
|
if (this.config.mode === "clone") {
|
||||||
|
this.updatedCipherView.id = null;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.updatedCipherView.type = this.config.cipherType;
|
this.updatedCipherView.type = this.config.cipherType;
|
||||||
|
|
||||||
|
@ -0,0 +1,110 @@
|
|||||||
|
<bit-section [formGroup]="loginDetailsForm">
|
||||||
|
<bit-section-header>
|
||||||
|
<h2 bitTypography="h5">
|
||||||
|
{{ "loginCredentials" | i18n }}
|
||||||
|
</h2>
|
||||||
|
</bit-section-header>
|
||||||
|
|
||||||
|
<bit-card>
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "username" | i18n }}</bit-label>
|
||||||
|
<input bitInput formControlName="username" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitIconButton="bwi-generate"
|
||||||
|
bitSuffix
|
||||||
|
*ngIf="loginDetailsForm.controls.username.enabled"
|
||||||
|
data-testid="generate-username-button"
|
||||||
|
[appA11yTitle]="'generateUsername' | i18n"
|
||||||
|
[bitAction]="generateUsername"
|
||||||
|
></button>
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "password" | i18n }}</bit-label>
|
||||||
|
<input bitInput formControlName="password" type="password" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitIconButton="bwi-check-circle"
|
||||||
|
bitSuffix
|
||||||
|
*ngIf="
|
||||||
|
loginDetailsForm.controls.password.enabled &&
|
||||||
|
loginDetailsForm.controls.password.value?.length > 0
|
||||||
|
"
|
||||||
|
data-testid="check-password-button"
|
||||||
|
[appA11yTitle]="'checkPassword' | i18n"
|
||||||
|
[bitAction]="checkPassword"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitIconButton
|
||||||
|
bitSuffix
|
||||||
|
*ngIf="viewHiddenFields"
|
||||||
|
data-testid="toggle-password-visibility"
|
||||||
|
bitPasswordInputToggle
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitIconButton="bwi-generate"
|
||||||
|
bitSuffix
|
||||||
|
*ngIf="loginDetailsForm.controls.password.enabled"
|
||||||
|
data-testid="generate-password-button"
|
||||||
|
[appA11yTitle]="'generatePassword' | i18n"
|
||||||
|
[bitAction]="generatePassword"
|
||||||
|
></button>
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<bit-form-field *ngIf="hasPasskey">
|
||||||
|
<bit-label>{{ "typePasskey" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
bitInput
|
||||||
|
disabled
|
||||||
|
[value]="fido2CredentialCreationDateValue"
|
||||||
|
data-testid="passkey-field"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitIconButton="bwi-minus-circle"
|
||||||
|
buttonType="danger"
|
||||||
|
bitSuffix
|
||||||
|
*ngIf="loginDetailsForm.enabled"
|
||||||
|
[bitAction]="removePasskey"
|
||||||
|
data-testid="remove-passkey-button"
|
||||||
|
[appA11yTitle]="'removePasskey' | i18n"
|
||||||
|
></button>
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>
|
||||||
|
{{ "authenticatorKey" | i18n }}
|
||||||
|
<button
|
||||||
|
bitIconButton="bwi-question-circle"
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
[bitPopoverTriggerFor]="totpPopover"
|
||||||
|
></button>
|
||||||
|
<bit-popover #totpPopover [title]="'totpHelperTitle' | i18n">
|
||||||
|
<p>{{ (canCaptureTotp ? "totpHelperWithCapture" : "totpHelper") | i18n }}</p>
|
||||||
|
</bit-popover>
|
||||||
|
</bit-label>
|
||||||
|
<input bitInput formControlName="totp" type="password" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitIconButton
|
||||||
|
bitSuffix
|
||||||
|
*ngIf="viewHiddenFields"
|
||||||
|
data-testid="toggle-totp-visibility"
|
||||||
|
bitPasswordInputToggle
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitIconButton="bwi-camera"
|
||||||
|
bitSuffix
|
||||||
|
*ngIf="canCaptureTotp"
|
||||||
|
data-testid="capture-totp-button"
|
||||||
|
[bitAction]="captureTotp"
|
||||||
|
[appA11yTitle]="'totpCapture' | i18n"
|
||||||
|
></button>
|
||||||
|
</bit-form-field>
|
||||||
|
</bit-card>
|
||||||
|
</bit-section>
|
@ -0,0 +1,503 @@
|
|||||||
|
import { DatePipe } from "@angular/common";
|
||||||
|
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||||
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
|
||||||
|
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||||
|
import { ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service";
|
||||||
|
import { TotpCaptureService } from "../../abstractions/totp-capture.service";
|
||||||
|
import { CipherFormContainer } from "../../cipher-form-container";
|
||||||
|
|
||||||
|
import { LoginDetailsSectionComponent } from "./login-details-section.component";
|
||||||
|
|
||||||
|
describe("LoginDetailsSectionComponent", () => {
|
||||||
|
let component: LoginDetailsSectionComponent;
|
||||||
|
let fixture: ComponentFixture<LoginDetailsSectionComponent>;
|
||||||
|
|
||||||
|
let cipherFormContainer: MockProxy<CipherFormContainer>;
|
||||||
|
let generationService: MockProxy<CipherFormGenerationService>;
|
||||||
|
let auditService: MockProxy<AuditService>;
|
||||||
|
let toastService: MockProxy<ToastService>;
|
||||||
|
let totpCaptureService: MockProxy<TotpCaptureService>;
|
||||||
|
let i18nService: MockProxy<I18nService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
cipherFormContainer = mock<CipherFormContainer>();
|
||||||
|
|
||||||
|
generationService = mock<CipherFormGenerationService>();
|
||||||
|
auditService = mock<AuditService>();
|
||||||
|
toastService = mock<ToastService>();
|
||||||
|
totpCaptureService = mock<TotpCaptureService>();
|
||||||
|
i18nService = mock<I18nService>();
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [LoginDetailsSectionComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: CipherFormContainer, useValue: cipherFormContainer },
|
||||||
|
{ provide: CipherFormGenerationService, useValue: generationService },
|
||||||
|
{ provide: AuditService, useValue: auditService },
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
{ provide: TotpCaptureService, useValue: totpCaptureService },
|
||||||
|
{ provide: I18nService, useValue: i18nService },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(LoginDetailsSectionComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers 'loginDetailsForm' form with CipherFormContainer", () => {
|
||||||
|
expect(cipherFormContainer.registerChildForm).toHaveBeenCalledWith(
|
||||||
|
"loginDetails",
|
||||||
|
component.loginDetailsForm,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patches 'loginDetailsForm' changes to CipherFormContainer", () => {
|
||||||
|
component.loginDetailsForm.patchValue({
|
||||||
|
username: "new-username",
|
||||||
|
password: "secret-password",
|
||||||
|
totp: "123456",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cipherFormContainer.patchCipher).toHaveBeenLastCalledWith({
|
||||||
|
login: expect.objectContaining({
|
||||||
|
username: "new-username",
|
||||||
|
password: "secret-password",
|
||||||
|
totp: "123456",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables 'loginDetailsForm' when in partial-edit mode", async () => {
|
||||||
|
cipherFormContainer.config.mode = "partial-edit";
|
||||||
|
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
expect(component.loginDetailsForm.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes 'loginDetailsForm' with original cipher view values", async () => {
|
||||||
|
(cipherFormContainer.originalCipherView as CipherView) = {
|
||||||
|
viewPassword: true,
|
||||||
|
login: {
|
||||||
|
password: "original-password",
|
||||||
|
username: "original-username",
|
||||||
|
totp: "original-totp",
|
||||||
|
} as LoginView,
|
||||||
|
} as CipherView;
|
||||||
|
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
expect(component.loginDetailsForm.value).toEqual({
|
||||||
|
username: "original-username",
|
||||||
|
password: "original-password",
|
||||||
|
totp: "original-totp",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes 'loginDetailsForm' with generated password when creating a new cipher", async () => {
|
||||||
|
generationService.generateInitialPassword.mockResolvedValue("generated-password");
|
||||||
|
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
expect(component.loginDetailsForm.controls.password.value).toBe("generated-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("viewHiddenFields", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(cipherFormContainer.originalCipherView as CipherView) = {
|
||||||
|
viewPassword: false,
|
||||||
|
login: {
|
||||||
|
password: "original-password",
|
||||||
|
} as LoginView,
|
||||||
|
} as CipherView;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns value of originalCipher.viewPassword", () => {
|
||||||
|
(cipherFormContainer.originalCipherView as CipherView).viewPassword = true;
|
||||||
|
|
||||||
|
expect(component.viewHiddenFields).toBe(true);
|
||||||
|
|
||||||
|
(cipherFormContainer.originalCipherView as CipherView).viewPassword = false;
|
||||||
|
|
||||||
|
expect(component.viewHiddenFields).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when creating a new cipher", () => {
|
||||||
|
(cipherFormContainer.originalCipherView as CipherView) = null;
|
||||||
|
|
||||||
|
expect(component.viewHiddenFields).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the password and totp fields when passwords are hidden for the original cipher", async () => {
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
expect(component.loginDetailsForm.controls.password.disabled).toBe(true);
|
||||||
|
expect(component.loginDetailsForm.controls.totp.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still provides original values for hidden fields when passwords are hidden", async () => {
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
component.loginDetailsForm.patchValue({
|
||||||
|
username: "new-username",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cipherFormContainer.patchCipher).toHaveBeenLastCalledWith({
|
||||||
|
login: expect.objectContaining({
|
||||||
|
username: "new-username",
|
||||||
|
password: "original-password",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("username", () => {
|
||||||
|
const getGenerateUsernameBtn = () =>
|
||||||
|
fixture.nativeElement.querySelector("button[data-testid='generate-username-button']");
|
||||||
|
|
||||||
|
it("should show generate username button when editable", () => {
|
||||||
|
expect(getGenerateUsernameBtn()).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide generate username button when not editable", fakeAsync(() => {
|
||||||
|
component.loginDetailsForm.controls.username.disable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(getGenerateUsernameBtn()).toBeNull();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("should generate a username when the generate username button is clicked", fakeAsync(() => {
|
||||||
|
generationService.generateUsername.mockResolvedValue("generated-username");
|
||||||
|
|
||||||
|
getGenerateUsernameBtn().click();
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(component.loginDetailsForm.controls.username.value).toEqual("generated-username");
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("should not replace an existing username if generation returns null", fakeAsync(() => {
|
||||||
|
generationService.generateUsername.mockResolvedValue(null);
|
||||||
|
|
||||||
|
getGenerateUsernameBtn().click();
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
const usernameSpy = jest.spyOn(component.loginDetailsForm.controls.username, "patchValue");
|
||||||
|
|
||||||
|
expect(usernameSpy).not.toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password", () => {
|
||||||
|
const getGeneratePasswordBtn = () =>
|
||||||
|
fixture.nativeElement.querySelector("button[data-testid='generate-password-button']");
|
||||||
|
|
||||||
|
const getCheckPasswordBtn = () =>
|
||||||
|
fixture.nativeElement.querySelector("button[data-testid='check-password-button']");
|
||||||
|
|
||||||
|
const getTogglePasswordVisibilityBtn = () =>
|
||||||
|
fixture.nativeElement.querySelector("button[data-testid='toggle-password-visibility']");
|
||||||
|
|
||||||
|
it("should show the password visibility toggle button based on viewHiddenFields", () => {
|
||||||
|
jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(getTogglePasswordVisibilityBtn()).not.toBeNull();
|
||||||
|
|
||||||
|
jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(getTogglePasswordVisibilityBtn()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password generation", () => {
|
||||||
|
it("should show generate password button when editable", () => {
|
||||||
|
expect(getGeneratePasswordBtn()).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide generate password button when not editable", fakeAsync(() => {
|
||||||
|
component.loginDetailsForm.controls.password.disable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getGeneratePasswordBtn()).toBeNull();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("should generate a password when the generate password button is clicked", fakeAsync(() => {
|
||||||
|
generationService.generatePassword.mockResolvedValue("generated-password");
|
||||||
|
|
||||||
|
getGeneratePasswordBtn().click();
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(component.loginDetailsForm.controls.password.value).toEqual("generated-password");
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("should not replace an existing password if generation returns null", fakeAsync(() => {
|
||||||
|
generationService.generatePassword.mockResolvedValue(null);
|
||||||
|
|
||||||
|
getGeneratePasswordBtn().click();
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
const passwordSpy = jest.spyOn(component.loginDetailsForm.controls.password, "patchValue");
|
||||||
|
|
||||||
|
expect(passwordSpy).not.toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password checking", () => {
|
||||||
|
it("should show the password check button when a password is present and editable", () => {
|
||||||
|
component.loginDetailsForm.controls.password.setValue("password");
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(getCheckPasswordBtn()).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide the password check button when the password is missing", () => {
|
||||||
|
component.loginDetailsForm.controls.password.setValue(null);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(getCheckPasswordBtn()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide the password check button when the password is not editable", () => {
|
||||||
|
component.loginDetailsForm.controls.password.disable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(getCheckPasswordBtn()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call checkPassword when the password check button is clicked", fakeAsync(() => {
|
||||||
|
component.checkPassword = jest.fn();
|
||||||
|
component.loginDetailsForm.controls.password.setValue("password");
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
getCheckPasswordBtn().click();
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(component.checkPassword).toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("checkPassword", () => {
|
||||||
|
it("should not call the audit service when the password is empty", async () => {
|
||||||
|
component.loginDetailsForm.controls.password.setValue(null);
|
||||||
|
|
||||||
|
await component.checkPassword();
|
||||||
|
|
||||||
|
expect(auditService.passwordLeaked).not.toHaveBeenCalled();
|
||||||
|
expect(toastService.showToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show a warning toast when the password has been exposed in a data breach", async () => {
|
||||||
|
component.loginDetailsForm.controls.password.setValue("password");
|
||||||
|
auditService.passwordLeaked.mockResolvedValue(1);
|
||||||
|
i18nService.t.mockReturnValue("passwordExposedMsg");
|
||||||
|
|
||||||
|
await component.checkPassword();
|
||||||
|
|
||||||
|
expect(auditService.passwordLeaked).toHaveBeenCalledWith("password");
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "warning",
|
||||||
|
title: null,
|
||||||
|
message: "passwordExposedMsg",
|
||||||
|
});
|
||||||
|
expect(i18nService.t).toHaveBeenCalledWith("passwordExposed", "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show a success toast when the password has not been exposed in a data breach", async () => {
|
||||||
|
component.loginDetailsForm.controls.password.setValue("password");
|
||||||
|
auditService.passwordLeaked.mockResolvedValue(0);
|
||||||
|
i18nService.t.mockReturnValue("passwordSafeMsg");
|
||||||
|
|
||||||
|
await component.checkPassword();
|
||||||
|
|
||||||
|
expect(auditService.passwordLeaked).toHaveBeenCalledWith("password");
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "success",
|
||||||
|
title: null,
|
||||||
|
message: "passwordSafeMsg",
|
||||||
|
});
|
||||||
|
expect(i18nService.t).toHaveBeenCalledWith("passwordSafe");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("totp", () => {
|
||||||
|
const getToggleTotpVisibilityBtn = () =>
|
||||||
|
fixture.nativeElement.querySelector("button[data-testid='toggle-totp-visibility']");
|
||||||
|
|
||||||
|
const getCaptureTotpBtn = () =>
|
||||||
|
fixture.nativeElement.querySelector("button[data-testid='capture-totp-button']");
|
||||||
|
|
||||||
|
it("should show the totp visibility toggle button based on viewHiddenFields", () => {
|
||||||
|
jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(getToggleTotpVisibilityBtn()).not.toBeNull();
|
||||||
|
|
||||||
|
jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(getToggleTotpVisibilityBtn()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the totp capture button based on canCaptureTotp", () => {
|
||||||
|
jest.spyOn(component, "canCaptureTotp", "get").mockReturnValue(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(getCaptureTotpBtn()).not.toBeNull();
|
||||||
|
|
||||||
|
jest.spyOn(component, "canCaptureTotp", "get").mockReturnValue(false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(getCaptureTotpBtn()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call captureTotp when the capture totp button is clicked", fakeAsync(() => {
|
||||||
|
component.captureTotp = jest.fn();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
getCaptureTotpBtn().click();
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(component.captureTotp).toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("canCaptureTotp", () => {
|
||||||
|
it("should return true when totpCaptureService is present and totp is editable", () => {
|
||||||
|
component.loginDetailsForm.controls.totp.enable();
|
||||||
|
expect(component.canCaptureTotp).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when totpCaptureService is missing", () => {
|
||||||
|
(component as any).totpCaptureService = null;
|
||||||
|
expect(component.canCaptureTotp).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when totp is disabled", () => {
|
||||||
|
component.loginDetailsForm.controls.totp.disable();
|
||||||
|
expect(component.canCaptureTotp).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("captureTotp", () => {
|
||||||
|
it("should not call totpCaptureService.captureTotpSecret when canCaptureTotp is false", async () => {
|
||||||
|
jest.spyOn(component, "canCaptureTotp", "get").mockReturnValue(false);
|
||||||
|
await component.captureTotp();
|
||||||
|
expect(totpCaptureService.captureTotpSecret).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should patch the totp value when totpCaptureService.captureTotpSecret returns a value", async () => {
|
||||||
|
jest.spyOn(component, "canCaptureTotp", "get").mockReturnValue(true);
|
||||||
|
totpCaptureService.captureTotpSecret.mockResolvedValue("some-totp-secret");
|
||||||
|
i18nService.t.mockReturnValue("totpCaptureSuccessMsg");
|
||||||
|
|
||||||
|
await component.captureTotp();
|
||||||
|
|
||||||
|
expect(component.loginDetailsForm.controls.totp.value).toBe("some-totp-secret");
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "success",
|
||||||
|
title: null,
|
||||||
|
message: "totpCaptureSuccessMsg",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show an error toast when totpCaptureService.captureTotpSecret throws", async () => {
|
||||||
|
jest.spyOn(component, "canCaptureTotp", "get").mockReturnValue(true);
|
||||||
|
totpCaptureService.captureTotpSecret.mockRejectedValue(new Error());
|
||||||
|
i18nService.t.mockReturnValueOnce("errorOccurredMsg");
|
||||||
|
i18nService.t.mockReturnValueOnce("totpCaptureErrorMsg");
|
||||||
|
|
||||||
|
const totpSpy = jest.spyOn(component.loginDetailsForm.controls.totp, "patchValue");
|
||||||
|
|
||||||
|
await component.captureTotp();
|
||||||
|
|
||||||
|
expect(totpSpy).not.toHaveBeenCalled();
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
title: "errorOccurredMsg",
|
||||||
|
message: "totpCaptureErrorMsg",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("passkeys", () => {
|
||||||
|
const passkeyDate = new Date();
|
||||||
|
const dateSpy = jest
|
||||||
|
.spyOn(DatePipe.prototype, "transform")
|
||||||
|
.mockReturnValue(passkeyDate.toString());
|
||||||
|
|
||||||
|
const getRemovePasskeyBtn = () =>
|
||||||
|
fixture.nativeElement.querySelector("button[data-testid='remove-passkey-button']");
|
||||||
|
|
||||||
|
const getPasskeyField = () =>
|
||||||
|
fixture.nativeElement.querySelector("input[data-testid='passkey-field']");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(cipherFormContainer.originalCipherView as CipherView) = {
|
||||||
|
login: Object.assign(new LoginView(), {
|
||||||
|
fido2Credentials: [{ creationDate: passkeyDate } as Fido2CredentialView],
|
||||||
|
}),
|
||||||
|
} as CipherView;
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(LoginDetailsSectionComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the passkey field when available", () => {
|
||||||
|
i18nService.t.mockReturnValue("Created");
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const passkeyField = getPasskeyField();
|
||||||
|
|
||||||
|
expect(passkeyField).not.toBeNull();
|
||||||
|
expect(dateSpy).toHaveBeenLastCalledWith(passkeyDate, "short");
|
||||||
|
expect(passkeyField.value).toBe("Created " + passkeyDate.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the passkey remove button when editable", () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getRemovePasskeyBtn).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the passkey remove button when not editable", () => {
|
||||||
|
cipherFormContainer.config.mode = "partial-edit";
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getRemovePasskeyBtn()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the passkey field when missing a passkey", () => {
|
||||||
|
(cipherFormContainer.originalCipherView as CipherView).login.fido2Credentials = [];
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getPasskeyField()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove the passkey when the remove button is clicked", fakeAsync(() => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
getRemovePasskeyBtn().click();
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(cipherFormContainer.patchCipher).toHaveBeenLastCalledWith({
|
||||||
|
login: expect.objectContaining({
|
||||||
|
fido2Credentials: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,228 @@
|
|||||||
|
import { DatePipe, NgIf } from "@angular/common";
|
||||||
|
import { Component, inject, OnInit, Optional } from "@angular/core";
|
||||||
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
|
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||||
|
import { map } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||||
|
import {
|
||||||
|
AsyncActionsModule,
|
||||||
|
CardComponent,
|
||||||
|
FormFieldModule,
|
||||||
|
IconButtonModule,
|
||||||
|
PopoverModule,
|
||||||
|
SectionComponent,
|
||||||
|
SectionHeaderComponent,
|
||||||
|
ToastService,
|
||||||
|
TypographyModule,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service";
|
||||||
|
import { TotpCaptureService } from "../../abstractions/totp-capture.service";
|
||||||
|
import { CipherFormContainer } from "../../cipher-form-container";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "vault-login-details-section",
|
||||||
|
templateUrl: "./login-details-section.component.html",
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
SectionComponent,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
SectionHeaderComponent,
|
||||||
|
TypographyModule,
|
||||||
|
JslibModule,
|
||||||
|
CardComponent,
|
||||||
|
FormFieldModule,
|
||||||
|
IconButtonModule,
|
||||||
|
AsyncActionsModule,
|
||||||
|
NgIf,
|
||||||
|
PopoverModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class LoginDetailsSectionComponent implements OnInit {
|
||||||
|
loginDetailsForm = this.formBuilder.group({
|
||||||
|
username: [""],
|
||||||
|
password: [""],
|
||||||
|
totp: [""],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the TOTP field can be captured from the current tab. Only available in the browser extension.
|
||||||
|
*/
|
||||||
|
get canCaptureTotp() {
|
||||||
|
return this.totpCaptureService != null && this.loginDetailsForm.controls.totp.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private datePipe = inject(DatePipe);
|
||||||
|
|
||||||
|
private loginView: LoginView;
|
||||||
|
|
||||||
|
get hasPasskey(): boolean {
|
||||||
|
return this.loginView?.hasFido2Credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
get fido2CredentialCreationDateValue(): string {
|
||||||
|
const dateCreated = this.i18nService.t("dateCreated");
|
||||||
|
const creationDate = this.datePipe.transform(
|
||||||
|
this.loginView?.fido2Credentials?.[0]?.creationDate,
|
||||||
|
"short",
|
||||||
|
);
|
||||||
|
return `${dateCreated} ${creationDate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewHiddenFields() {
|
||||||
|
if (this.cipherFormContainer.originalCipherView) {
|
||||||
|
return this.cipherFormContainer.originalCipherView.viewPassword;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private cipherFormContainer: CipherFormContainer,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
private generationService: CipherFormGenerationService,
|
||||||
|
private auditService: AuditService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
@Optional() private totpCaptureService?: TotpCaptureService,
|
||||||
|
) {
|
||||||
|
this.cipherFormContainer.registerChildForm("loginDetails", this.loginDetailsForm);
|
||||||
|
|
||||||
|
this.loginDetailsForm.valueChanges
|
||||||
|
.pipe(
|
||||||
|
takeUntilDestroyed(),
|
||||||
|
// getRawValue() is used as fields can be disabled when passwords are hidden
|
||||||
|
map(() => this.loginDetailsForm.getRawValue()),
|
||||||
|
)
|
||||||
|
.subscribe((value) => {
|
||||||
|
Object.assign(this.loginView, {
|
||||||
|
username: value.username,
|
||||||
|
password: value.password,
|
||||||
|
totp: value.totp,
|
||||||
|
} as LoginView);
|
||||||
|
|
||||||
|
this.cipherFormContainer.patchCipher({
|
||||||
|
login: this.loginView,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
this.loginView = new LoginView();
|
||||||
|
if (this.cipherFormContainer.originalCipherView?.login) {
|
||||||
|
this.initFromExistingCipher(this.cipherFormContainer.originalCipherView.login);
|
||||||
|
} else {
|
||||||
|
await this.initNewCipher();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cipherFormContainer.config.mode === "partial-edit") {
|
||||||
|
this.loginDetailsForm.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initFromExistingCipher(existingLogin: LoginView) {
|
||||||
|
// Note: this.loginView will still contain references to the existing login's Uri and Fido2Credential arrays.
|
||||||
|
// We may need to deep clone these in the future.
|
||||||
|
Object.assign(this.loginView, existingLogin);
|
||||||
|
this.loginDetailsForm.patchValue({
|
||||||
|
username: this.loginView.username,
|
||||||
|
password: this.loginView.password,
|
||||||
|
totp: this.loginView.totp,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.viewHiddenFields) {
|
||||||
|
this.loginDetailsForm.controls.password.disable();
|
||||||
|
this.loginDetailsForm.controls.totp.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initNewCipher() {
|
||||||
|
this.loginDetailsForm.controls.password.patchValue(
|
||||||
|
await this.generationService.generateInitialPassword(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
captureTotp = async () => {
|
||||||
|
if (!this.canCaptureTotp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const totp = await this.totpCaptureService.captureTotpSecret();
|
||||||
|
if (totp) {
|
||||||
|
this.loginDetailsForm.controls.totp.patchValue(totp);
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: null,
|
||||||
|
message: this.i18nService.t("totpCaptureSuccess"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("errorOccurred"),
|
||||||
|
message: this.i18nService.t("totpCaptureError"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
removePasskey = async () => {
|
||||||
|
// Fido2Credentials do not have a form control, so update directly
|
||||||
|
this.loginView.fido2Credentials = null;
|
||||||
|
this.cipherFormContainer.patchCipher({
|
||||||
|
login: this.loginView,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new password and update the form.
|
||||||
|
* TODO: Browser extension needs a means to cache the current form so values are not lost upon navigating to the generator.
|
||||||
|
*/
|
||||||
|
generatePassword = async () => {
|
||||||
|
const newPassword = await this.generationService.generatePassword();
|
||||||
|
|
||||||
|
if (newPassword) {
|
||||||
|
this.loginDetailsForm.controls.password.patchValue(newPassword);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new username and update the form.
|
||||||
|
* TODO: Browser extension needs a means to cache the current form so values are not lost upon navigating to the generator.
|
||||||
|
*/
|
||||||
|
generateUsername = async () => {
|
||||||
|
const newUsername = await this.generationService.generateUsername();
|
||||||
|
if (newUsername) {
|
||||||
|
this.loginDetailsForm.controls.username.patchValue(newUsername);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the password has been exposed in a data breach using the AuditService.
|
||||||
|
*/
|
||||||
|
checkPassword = async () => {
|
||||||
|
const password = this.loginDetailsForm.controls.password.value;
|
||||||
|
|
||||||
|
if (password == null || password === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await this.auditService.passwordLeaked(password);
|
||||||
|
|
||||||
|
if (matches > 0) {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "warning",
|
||||||
|
title: null,
|
||||||
|
message: this.i18nService.t("passwordExposed", matches.toString()),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: null,
|
||||||
|
message: this.i18nService.t("passwordSafe"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -5,4 +5,6 @@ export {
|
|||||||
CipherFormMode,
|
CipherFormMode,
|
||||||
OptionalInitialValues,
|
OptionalInitialValues,
|
||||||
} from "./abstractions/cipher-form-config.service";
|
} from "./abstractions/cipher-form-config.service";
|
||||||
|
export { TotpCaptureService } from "./abstractions/totp-capture.service";
|
||||||
|
export { CipherFormGenerationService } from "./abstractions/cipher-form-generation.service";
|
||||||
export { DefaultCipherFormConfigService } from "./services/default-cipher-form-config.service";
|
export { DefaultCipherFormConfigService } from "./services/default-cipher-form-config.service";
|
||||||
|
@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from "@angular/core";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PasswordGenerationServiceAbstraction,
|
||||||
|
UsernameGenerationServiceAbstraction,
|
||||||
|
} from "@bitwarden/generator-legacy";
|
||||||
|
|
||||||
|
import { CipherFormGenerationService } from "../abstractions/cipher-form-generation.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DefaultCipherFormGenerationService implements CipherFormGenerationService {
|
||||||
|
constructor(
|
||||||
|
private passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||||
|
private usernameGenerationService: UsernameGenerationServiceAbstraction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generatePassword(): Promise<string> {
|
||||||
|
const [options] = await this.passwordGenerationService.getOptions();
|
||||||
|
return await this.passwordGenerationService.generatePassword(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateUsername(): Promise<string> {
|
||||||
|
const options = await this.usernameGenerationService.getOptions();
|
||||||
|
return await this.usernameGenerationService.generateUsername(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateInitialPassword(): Promise<string> {
|
||||||
|
return await this.generatePassword();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user