From 2a0e21b4bb63a8c2f18d3ec92d7e6afa161182d3 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Mon, 17 Jun 2024 14:56:24 -0700 Subject: [PATCH] [PM-5085] Create InputPasswordComponent (#9630) * setup for InputPasswordComponent and basic story * add all input fields * add translated error messages * update validation * add password-callout * update hint text * use PolicyService in component * setup SetPasswordComponent * remove div * add default button text * add mocks for InputPassword storybook * simplify ngOnInit * change param and use PolicyApiService * check for breaches and validate against policy * user toastService * use useValue for mocks * hash before emitting * validation cleanup and use PreloadedEnglishI18nModule * add ngOnDestroy * create validateFormInputsDoNotMatch fn * update validateFormInputsComparison and add deprecation jsdocs * rename validator fn * fix bugs in validation fn * cleanup and re-introduce services/logic * toggle password inputs together * update hint help text * remove SetPassword test * remove master key creation / hashing * add translations to browser/desktop * mock basic password-strength functionality * add check for controls * hash before emitting * type the EventEmitter * use DEFAULT_KDF_CONFIG * emit master key * clarify comment * update password mininum help text to match org policy requirement --- apps/browser/src/_locales/en/messages.json | 13 ++ apps/desktop/src/locales/en/messages.json | 13 ++ apps/web/src/locales/en/messages.json | 13 ++ .../inputs-field-match.validator.ts | 116 ++++++++++- libs/auth/src/angular/index.ts | 1 + .../input-password.component.html | 73 +++++++ .../input-password.component.ts | 192 ++++++++++++++++++ .../input-password/input-password.stories.ts | 116 +++++++++++ 8 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 libs/auth/src/angular/input-password/input-password.component.html create mode 100644 libs/auth/src/angular/input-password/input-password.component.ts create mode 100644 libs/auth/src/angular/input-password/input-password.stories.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 79ea4d4856d..4d786ddcbb7 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -49,6 +49,19 @@ "masterPassHintDesc": { "message": "A master password hint can help you remember your password if you forget it." }, + "masterPassHintText": { + "message": "If you forget your password, the password hint can be sent to your email. $CURRENT$/$MAXIMUM$ character maximum.", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "maximum": { + "content": "$2", + "example": "50" + } + } + }, "reTypeMasterPass": { "message": "Re-type master password" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 73e1bc56e66..d0589116fb6 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -526,6 +526,19 @@ "masterPassHint": { "message": "Master password hint (optional)" }, + "masterPassHintText": { + "message": "If you forget your password, the password hint can be sent to your email. $CURRENT$/$MAXIMUM$ character maximum.", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "maximum": { + "content": "$2", + "example": "50" + } + } + }, "settings": { "message": "Settings" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index c248b04dc06..87c66f9b733 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -767,6 +767,19 @@ "masterPassHintLabel": { "message": "Master password hint" }, + "masterPassHintText": { + "message": "If you forget your password, the password hint can be sent to your email. $CURRENT$/$MAXIMUM$ character maximum.", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "maximum": { + "content": "$2", + "example": "50" + } + } + }, "settings": { "message": "Settings" }, diff --git a/libs/angular/src/auth/validators/inputs-field-match.validator.ts b/libs/angular/src/auth/validators/inputs-field-match.validator.ts index 9c91007d0ca..5f3acd73bc6 100644 --- a/libs/angular/src/auth/validators/inputs-field-match.validator.ts +++ b/libs/angular/src/auth/validators/inputs-field-match.validator.ts @@ -1,9 +1,13 @@ -import { AbstractControl, UntypedFormGroup, ValidatorFn } from "@angular/forms"; +import { AbstractControl, UntypedFormGroup, ValidationErrors, ValidatorFn } from "@angular/forms"; import { FormGroupControls } from "../../platform/abstractions/form-validation-errors.service"; export class InputsFieldMatch { - //check to ensure two fields do not have the same value + /** + * Check to ensure two fields do not have the same value + * + * @deprecated Use compareInputs() instead + */ static validateInputsDoesntMatch(matchTo: string, errorMessage: string): ValidatorFn { return (control: AbstractControl) => { if (control.parent && control.parent.controls) { @@ -37,7 +41,18 @@ export class InputsFieldMatch { }; } - //checks the formGroup if two fields have the same value and validation is controlled from either field + /** + * Checks the formGroup if two fields have the same value and validation is controlled from either field + * + * @deprecated + * Use compareInputs() instead. + * + * For more info on deprecation + * - Do not use untyped `options` object in formBuilder.group() {@link https://angular.dev/api/forms/UntypedFormBuilder} + * - Use formBuilder.group() overload with AbstractControlOptions type instead {@link https://angular.dev/api/forms/AbstractControlOptions} + * + * Remove this method after deprecated instances are replaced + */ static validateFormInputsMatch(field: string, fieldMatchTo: string, errorMessage: string) { return (formGroup: UntypedFormGroup) => { const fieldCtrl = formGroup.controls[field]; @@ -54,4 +69,99 @@ export class InputsFieldMatch { } }; } + + /** + * Checks whether two form controls do or do not have the same input value (except for empty string values). + * + * - Validation is controlled from either form control. + * - The error message is displayed under controlB by default, but can be set to controlA. + * + * @param validationGoal Whether you want to verify that the form control input values match or do not match + * @param controlNameA The name of the first form control to compare. + * @param controlNameB The name of the second form control to compare. + * @param errorMessage The error message to display if there is an error. This will probably + * be an i18n translated string. + * @param showErrorOn The control under which you want to display the error (default is controlB). + */ + static compareInputs( + validationGoal: "match" | "doNotMatch", + controlNameA: string, + controlNameB: string, + errorMessage: string, + showErrorOn: "controlA" | "controlB" = "controlB", + ): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const controlA = control.get(controlNameA); + const controlB = control.get(controlNameB); + + if (!controlA || !controlB) { + return null; + } + + const controlThatShowsError = showErrorOn === "controlA" ? controlA : controlB; + + // Don't compare empty strings + if (controlA.value === "" && controlB.value === "") { + return pass(); + } + + const controlValuesMatch = controlA.value === controlB.value; + + if (validationGoal === "match") { + if (controlValuesMatch) { + return pass(); + } else { + return fail(); + } + } + + if (validationGoal === "doNotMatch") { + if (!controlValuesMatch) { + return pass(); + } else { + return fail(); + } + } + + return null; // default return + + function fail() { + controlThatShowsError.setErrors({ + // Preserve any pre-existing errors + ...controlThatShowsError.errors, + // Add new inputMatchError + inputMatchError: { + message: errorMessage, + }, + }); + + return { + inputMatchError: { + message: errorMessage, + }, + }; + } + + function pass(): null { + // Get the current errors object + const errorsObj = controlThatShowsError?.errors; + + if (errorsObj != null) { + // Remove any inputMatchError if it exists, since that is the sole error we are targeting with this validator + if (errorsObj?.inputMatchError) { + delete errorsObj.inputMatchError; + } + + // Check if the errorsObj is now empty + const isEmptyObj = Object.keys(errorsObj).length === 0; + + // If the errorsObj is empty, set errors to null, otherwise set the errors to an object of pre-existing errors (other than inputMatchError) + controlThatShowsError.setErrors(isEmptyObj ? null : errorsObj); + } + + // Return null for this validator + return null; + } + }; + } } diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 9c660413cd3..6cb08db6f72 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -8,6 +8,7 @@ export * from "./icons"; export * from "./anon-layout/anon-layout.component"; export * from "./anon-layout/anon-layout-wrapper.component"; export * from "./fingerprint-dialog/fingerprint-dialog.component"; +export * from "./input-password/input-password.component"; export * from "./password-callout/password-callout.component"; // user verification diff --git a/libs/auth/src/angular/input-password/input-password.component.html b/libs/auth/src/angular/input-password/input-password.component.html new file mode 100644 index 00000000000..e6c36914cfd --- /dev/null +++ b/libs/auth/src/angular/input-password/input-password.component.html @@ -0,0 +1,73 @@ +
diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts new file mode 100644 index 00000000000..cc91e2a2552 --- /dev/null +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -0,0 +1,192 @@ +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { PBKDF2KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { MasterKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + CheckboxModule, + DialogService, + FormFieldModule, + IconButtonModule, + InputModule, + ToastService, +} from "@bitwarden/components"; + +import { InputsFieldMatch } from "../../../../angular/src/auth/validators/inputs-field-match.validator"; +import { SharedModule } from "../../../../components/src/shared"; +import { PasswordCalloutComponent } from "../password-callout/password-callout.component"; + +export interface PasswordInputResult { + masterKey: MasterKey; + masterKeyHash: string; + kdfConfig: PBKDF2KdfConfig; + hint: string; +} + +@Component({ + standalone: true, + selector: "auth-input-password", + templateUrl: "./input-password.component.html", + imports: [ + AsyncActionsModule, + ButtonModule, + CheckboxModule, + FormFieldModule, + IconButtonModule, + InputModule, + ReactiveFormsModule, + SharedModule, + PasswordCalloutComponent, + JslibModule, + ], +}) +export class InputPasswordComponent implements OnInit { + @Output() onPasswordFormSubmit = new EventEmitter