diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 79ea4d4856..4d786ddcbb 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 73e1bc56e6..d0589116fb 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 c248b04dc0..87c66f9b73 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 9c91007d0c..5f3acd73bc 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 9c660413cd..6cb08db6f7 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 0000000000..e6c36914cf --- /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 0000000000..cc91e2a255 --- /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