/* eslint-disable regexp/no-useless-escape */ import type { VNode } from 'vue'; import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper'; import { Prop, toNative } from 'vue-facing-decorator'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import FormItemWrapper from '../form/form-item-wrapper'; import './css/password-input.css'; export interface PasswordRequirements { minLength?: number; requireUppercase?: boolean; requireLowercase?: boolean; requireDigit?: boolean; requireSpecialChar?: boolean; } export class PasswordInputResources { static prefix = 'At least '; static minLength = '{0} characters'; static uppercase = '1 uppercase letter'; static lowercase = '1 lowercase letter'; static digit = '1 number'; static specialChar = '1 special character'; static weak = 'Weak'; static medium = 'Medium'; static strong = 'Strong'; } export enum PasswordStrength { None = 'none', Weak = 'weak', Medium = 'medium', Strong = 'strong', } interface PasswordInputArgs extends Omit { value: string; changed: (newValue: string) => void; placeholder?: string; disabled?: boolean; autoCompleteText?: string; requirements?: PasswordRequirements; showRequirements?: boolean; /** When true, reveal the requirements checklist immediately — even before the user types. */ alwaysShowRequirements?: boolean; nameAttr?: string; updateMode?: 'input' | 'change'; } @Component class PasswordInputComponent extends TsxComponent implements PasswordInputArgs { @Prop() label!: string | VNode; @Prop() value!: string; @Prop() placeholder!: string; @Prop() cssClass!: string; @Prop() mandatory!: boolean; @Prop() disabled!: boolean; @Prop() wrap!: boolean; @Prop() hint: string; @Prop() appendIcon: string; @Prop() prependIcon: string; @Prop() maxWidth?: number; @Prop() marginType?: MarginType; @Prop() nameAttr?: string; @Prop() autoCompleteText?: string; @Prop() changed: (newValue: string) => void; @Prop() updateMode?: 'input' | 'change'; @Prop() requirements?: PasswordRequirements; @Prop() showRequirements?: boolean; @Prop() alwaysShowRequirements?: boolean; isPasswordVisible: boolean = false; get currentValue(): string { return this.value || ''; } get meetsMinLength(): boolean { if (!this.requirements?.minLength) { return true; } return this.currentValue.length >= this.requirements.minLength; } get meetsUppercase(): boolean { if (!this.requirements?.requireUppercase) { return true; } return /[A-Z]/.test(this.currentValue); } get meetsLowercase(): boolean { if (!this.requirements?.requireLowercase) { return true; } return /[a-z]/.test(this.currentValue); } get meetsDigit(): boolean { if (!this.requirements?.requireDigit) { return true; } return /\d/.test(this.currentValue); } get meetsSpecialChar(): boolean { if (!this.requirements?.requireSpecialChar) { return true; } return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(this.currentValue); } get allRequirementsMet(): boolean { return this.meetsMinLength && this.meetsUppercase && this.meetsLowercase && this.meetsDigit && this.meetsSpecialChar; } get strength(): PasswordStrength { const val = this.currentValue; if (!val || val.length === 0) { return PasswordStrength.None; } let score = 0; if (val.length >= 8) { score++; } if (val.length >= 12) { score++; } if (/[A-Z]/.test(val)) { score++; } if (/[a-z]/.test(val)) { score++; } if (/\d/.test(val)) { score++; } if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(val)) { score++; } if (score <= 2) { return PasswordStrength.Weak; } if (score <= 4) { return PasswordStrength.Medium; } return PasswordStrength.Strong; } get strengthLabel(): string { switch (this.strength) { case PasswordStrength.Weak: return PasswordInputResources.weak; case PasswordStrength.Medium: return PasswordInputResources.medium; case PasswordStrength.Strong: return PasswordInputResources.strong; default: return ''; } } get hasRequirements(): boolean { if (!this.requirements) { return false; } return !!( this.requirements.minLength || this.requirements.requireUppercase || this.requirements.requireLowercase || this.requirements.requireDigit || this.requirements.requireSpecialChar ); } get isFeatureEnabled(): boolean { return this.showRequirements !== false && this.hasRequirements; } get shouldShowOverlay(): boolean { return this.isFeatureEnabled && (this.alwaysShowRequirements === true || this.currentValue.length > 0); } private togglePasswordVisibility(): void { this.isPasswordVisible = !this.isPasswordVisible; } private getTypeAttribute(): string { if (this.isPasswordVisible) { return 'text'; } return 'password'; } private getAutoCompleteText(): string { if (this.autoCompleteText) { return this.autoCompleteText; } return 'new-password'; } private raiseChangeEvent(e: Event): void { this.populateValidationDeclaration(); if (this.changed != null) { const newValue = (e.target as HTMLInputElement).value; this.changed(newValue); } } private get errorId(): string { return `password-input-error-${this.$.uid}`; } private renderRequirementItem(met: boolean, label: string) { return (
{label}
); } private renderOverlay() { if (!this.shouldShowOverlay) { return null; } const items: any[] = []; if (this.requirements.minLength) { const label = PasswordInputResources.minLength.replace('{0}', String(this.requirements.minLength)); items.push(this.renderRequirementItem(this.meetsMinLength, label)); } if (this.requirements.requireUppercase) { items.push(this.renderRequirementItem(this.meetsUppercase, PasswordInputResources.uppercase)); } if (this.requirements.requireLowercase) { items.push(this.renderRequirementItem(this.meetsLowercase, PasswordInputResources.lowercase)); } if (this.requirements.requireDigit) { items.push(this.renderRequirementItem(this.meetsDigit, PasswordInputResources.digit)); } if (this.requirements.requireSpecialChar) { items.push(this.renderRequirementItem(this.meetsSpecialChar, PasswordInputResources.specialChar)); } return (
{items}
); } private renderStrengthBar() { if (!this.isFeatureEnabled || this.currentValue.length === 0) { return null; } return (
); } render(h) { const hasError = this.hasValidationError; return ( this.togglePasswordVisibility()} prependIcon={this.prependIcon} hint={this.hint} marginType={this.marginType} maxWidth={this.maxWidth} validationState={this.validationState} errorId={this.errorId} > this.raiseChangeEvent(e)} onInput={(e) => { if (this.updateMode == 'input') { this.raiseChangeEvent(e); } }} class={PowerduckState.getFormControlCssClass()} placeholder={this.placeholder} autocomplete={this.getAutoCompleteText()} aria-invalid={hasError ? 'true' : undefined} aria-describedby={hasError ? this.errorId : undefined} aria-required={this.mandatory ? 'true' : undefined} /> {this.renderOverlay()} {this.renderStrengthBar()} ); } } const PasswordInput = toNative(PasswordInputComponent); export default PasswordInput;