mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
[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
This commit is contained in:
parent
75615902a3
commit
2a0e21b4bb
@ -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"
|
||||
},
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -0,0 +1,73 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<auth-password-callout
|
||||
*ngIf="masterPasswordPolicy"
|
||||
[policy]="masterPasswordPolicy"
|
||||
></auth-password-callout>
|
||||
|
||||
<div class="tw-mb-6">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "masterPassword" | i18n }}</bit-label>
|
||||
<input
|
||||
id="input-password-form_password"
|
||||
bitInput
|
||||
type="password"
|
||||
formControlName="password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton
|
||||
bitSuffix
|
||||
bitPasswordInputToggle
|
||||
[(toggled)]="showPassword"
|
||||
></button>
|
||||
<bit-hint>
|
||||
<span class="tw-font-bold">{{ "important" | i18n }} </span>
|
||||
{{ "masterPassImportant" | i18n }}
|
||||
{{ minPasswordMsg }}.
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<app-password-strength
|
||||
[password]="formGroup.controls.password.value"
|
||||
[email]="email"
|
||||
[showText]="true"
|
||||
(passwordStrengthResult)="getPasswordStrengthResult($event)"
|
||||
></app-password-strength>
|
||||
</div>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "confirmMasterPassword" | i18n }}</bit-label>
|
||||
<input
|
||||
id="input-password-form_confirmed-password"
|
||||
bitInput
|
||||
type="password"
|
||||
formControlName="confirmedPassword"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton
|
||||
bitSuffix
|
||||
bitPasswordInputToggle
|
||||
[(toggled)]="showPassword"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "masterPassHintLabel" | i18n }}</bit-label>
|
||||
<input bitInput formControlName="hint" />
|
||||
<bit-hint>
|
||||
{{ "masterPassHintText" | i18n: formGroup.value.hint.length : maxHintLength.toString() }}
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="checkForBreaches" />
|
||||
<bit-label>{{ "checkForBreaches" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" [block]="true">
|
||||
{{ buttonText || ("setMasterPassword" | i18n) }}
|
||||
</button>
|
||||
|
||||
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary>
|
||||
</form>
|
192
libs/auth/src/angular/input-password/input-password.component.ts
Normal file
192
libs/auth/src/angular/input-password/input-password.component.ts
Normal file
@ -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<PasswordInputResult>();
|
||||
|
||||
@Input({ required: true }) email: string;
|
||||
@Input() protected buttonText: string;
|
||||
@Input() private orgId: string;
|
||||
|
||||
private minHintLength = 0;
|
||||
protected maxHintLength = 50;
|
||||
|
||||
protected minPasswordLength = Utils.minimumPasswordLength;
|
||||
protected minPasswordMsg = "";
|
||||
protected masterPasswordPolicy: MasterPasswordPolicyOptions;
|
||||
protected passwordStrengthResult: any;
|
||||
protected showErrorSummary = false;
|
||||
protected showPassword = false;
|
||||
|
||||
protected formGroup = this.formBuilder.group(
|
||||
{
|
||||
password: ["", [Validators.required, Validators.minLength(this.minPasswordLength)]],
|
||||
confirmedPassword: ["", Validators.required],
|
||||
hint: [
|
||||
"", // must be string (not null) because we check length in validation
|
||||
[Validators.minLength(this.minHintLength), Validators.maxLength(this.maxHintLength)],
|
||||
],
|
||||
checkForBreaches: true,
|
||||
},
|
||||
{
|
||||
validators: [
|
||||
InputsFieldMatch.compareInputs(
|
||||
"match",
|
||||
"password",
|
||||
"confirmedPassword",
|
||||
this.i18nService.t("masterPassDoesntMatch"),
|
||||
),
|
||||
InputsFieldMatch.compareInputs(
|
||||
"doNotMatch",
|
||||
"password",
|
||||
"hint",
|
||||
this.i18nService.t("hintEqualsPassword"),
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
constructor(
|
||||
private auditService: AuditService,
|
||||
private cryptoService: CryptoService,
|
||||
private dialogService: DialogService,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
private policyService: PolicyService,
|
||||
private toastService: ToastService,
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.masterPasswordPolicy = await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(
|
||||
this.orgId,
|
||||
);
|
||||
|
||||
if (this.masterPasswordPolicy != null && this.masterPasswordPolicy.minLength > 0) {
|
||||
this.minPasswordMsg = this.i18nService.t(
|
||||
"characterMinimum",
|
||||
this.masterPasswordPolicy.minLength,
|
||||
);
|
||||
} else {
|
||||
this.minPasswordMsg = this.i18nService.t("characterMinimum", this.minPasswordLength);
|
||||
}
|
||||
}
|
||||
|
||||
getPasswordStrengthResult(result: any) {
|
||||
this.passwordStrengthResult = result;
|
||||
}
|
||||
|
||||
protected submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
this.showErrorSummary = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const password = this.formGroup.controls.password.value;
|
||||
|
||||
// Check if password is breached (if breached, user chooses to accept and continue or not)
|
||||
const passwordIsBreached =
|
||||
this.formGroup.controls.checkForBreaches.value &&
|
||||
(await this.auditService.passwordLeaked(password));
|
||||
|
||||
if (passwordIsBreached) {
|
||||
const userAcceptedDialog = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "exposedMasterPassword" },
|
||||
content: { key: "exposedMasterPasswordDesc" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!userAcceptedDialog) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if password meets org policy requirements
|
||||
if (
|
||||
this.masterPasswordPolicy != null &&
|
||||
!this.policyService.evaluateMasterPassword(
|
||||
this.passwordStrengthResult.score,
|
||||
password,
|
||||
this.masterPasswordPolicy,
|
||||
)
|
||||
) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and hash new master key
|
||||
const kdfConfig = DEFAULT_KDF_CONFIG;
|
||||
|
||||
const masterKey = await this.cryptoService.makeMasterKey(
|
||||
password,
|
||||
this.email.trim().toLowerCase(),
|
||||
kdfConfig,
|
||||
);
|
||||
|
||||
const masterKeyHash = await this.cryptoService.hashMasterKey(password, masterKey);
|
||||
|
||||
this.onPasswordFormSubmit.emit({
|
||||
masterKey,
|
||||
masterKeyHash,
|
||||
kdfConfig,
|
||||
hint: this.formGroup.controls.hint.value,
|
||||
});
|
||||
};
|
||||
}
|
116
libs/auth/src/angular/input-password/input-password.stories.ts
Normal file
116
libs/auth/src/angular/input-password/input-password.stories.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { importProvidersFrom } from "@angular/core";
|
||||
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import { Meta, StoryObj, applicationConfig } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
import { ZXCVBNResult } from "zxcvbn";
|
||||
|
||||
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 { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { PreloadedEnglishI18nModule } from "../../../../../apps/web/src/app/core/tests";
|
||||
|
||||
import { InputPasswordComponent } from "./input-password.component";
|
||||
|
||||
const mockMasterPasswordPolicyOptions = {
|
||||
minComplexity: 4,
|
||||
minLength: 14,
|
||||
requireUpper: true,
|
||||
requireLower: true,
|
||||
requireNumbers: true,
|
||||
requireSpecial: true,
|
||||
} as MasterPasswordPolicyOptions;
|
||||
|
||||
export default {
|
||||
title: "Auth/Input Password",
|
||||
component: InputPasswordComponent,
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [
|
||||
importProvidersFrom(PreloadedEnglishI18nModule),
|
||||
importProvidersFrom(BrowserAnimationsModule),
|
||||
{
|
||||
provide: AuditService,
|
||||
useValue: {
|
||||
passwordLeaked: () => Promise.resolve(1),
|
||||
} as Partial<AuditService>,
|
||||
},
|
||||
{
|
||||
provide: CryptoService,
|
||||
useValue: {
|
||||
makeMasterKey: () => Promise.resolve("example-master-key"),
|
||||
hashMasterKey: () => Promise.resolve("example-master-key-hash"),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: {
|
||||
openSimpleDialog: () => Promise.resolve(true),
|
||||
} as Partial<DialogService>,
|
||||
},
|
||||
{
|
||||
provide: PolicyApiServiceAbstraction,
|
||||
useValue: {
|
||||
getMasterPasswordPolicyOptsForOrgUser: () => mockMasterPasswordPolicyOptions,
|
||||
} as Partial<PolicyService>,
|
||||
},
|
||||
{
|
||||
provide: PolicyService,
|
||||
useValue: {
|
||||
masterPasswordPolicyOptions$: () => of(mockMasterPasswordPolicyOptions),
|
||||
evaluateMasterPassword: (score) => {
|
||||
if (score < 4) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
} as Partial<PolicyService>,
|
||||
},
|
||||
{
|
||||
provide: PasswordStrengthServiceAbstraction,
|
||||
useValue: {
|
||||
getPasswordStrength: (password) => {
|
||||
let score = 0;
|
||||
|
||||
if (password.length === 0) {
|
||||
score = null;
|
||||
} else if (password.length <= 4) {
|
||||
score = 1;
|
||||
} else if (password.length <= 8) {
|
||||
score = 2;
|
||||
} else if (password.length <= 12) {
|
||||
score = 3;
|
||||
} else {
|
||||
score = 4;
|
||||
}
|
||||
|
||||
return { score } as ZXCVBNResult;
|
||||
},
|
||||
} as Partial<PasswordStrengthServiceAbstraction>,
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: {
|
||||
showToast: action("ToastService.showToast"),
|
||||
} as Partial<ToastService>,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<InputPasswordComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<auth-input-password></auth-input-password>
|
||||
`,
|
||||
}),
|
||||
};
|
Loading…
Reference in New Issue
Block a user