diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html index 5b661963c6..4d7af5bfd0 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.html +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html @@ -40,7 +40,7 @@ profile.current-password - + {{ 'security.password-requirement.incorrect-password-try-again' | translate }} @@ -52,13 +52,13 @@ + && !changePassword.get('newPassword').hasError('passwordSameAsOld')"> {{ 'security.password-requirement.password-not-meet-requirements' | translate }} {{ changePassword.get('newPassword').getError('alreadyUsed') }} - + {{ 'security.password-requirement.password-should-difference' | translate }} @@ -72,7 +72,7 @@ login.new-password-again - + {{ 'security.password-requirement.new-passwords-not-match' | translate }} @@ -134,7 +134,7 @@ diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.ts b/ui-ngx/src/app/modules/home/pages/security/security.component.ts index 32094d9677..4bad58a764 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.ts @@ -23,7 +23,6 @@ import { AbstractControl, UntypedFormBuilder, UntypedFormGroup, FormGroupDirective, - NgForm, ValidationErrors, ValidatorFn, Validators @@ -48,10 +47,14 @@ import { import { authenticationDialogMap } from '@home/pages/security/authentication-dialog/authentication-dialog.map'; import { takeUntil, tap } from 'rxjs/operators'; import { Observable, of, Subject } from 'rxjs'; -import { isDefinedAndNotNull, isEqual } from '@core/utils'; +import { isDefinedAndNotNull } from '@core/utils'; import { AuthService } from '@core/auth/auth.service'; import { UserPasswordPolicy } from '@shared/models/settings.models'; import { MatCheckboxChange } from '@angular/material/checkbox'; +import { + passwordsMatchValidator, + passwordStrengthValidator +} from '@shared/models/password.models'; @Component({ selector: 'tb-security', @@ -164,7 +167,12 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro this.changePassword = this.fb.group({ currentPassword: [''], newPassword: ['', Validators.required], - newPassword2: ['', this.samePasswordValidation(false, 'newPassword')] + newPassword2: [''] + }, { + validators: [ + this.passwordNotSameAsOld(), + passwordsMatchValidator('newPassword', 'newPassword2'), + ] }); } @@ -172,64 +180,36 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro this.authService.getUserPasswordPolicy().subscribe(policy => { this.passwordPolicy = policy; this.changePassword.get('newPassword').setValidators([ - this.passwordStrengthValidator(), - this.samePasswordValidation(true, 'currentPassword'), + passwordStrengthValidator(this.passwordPolicy), Validators.required ]); this.changePassword.get('newPassword').updateValueAndValidity({emitEvent: false}); }); } - private passwordStrengthValidator(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - const value: string = control.value; - const errors: any = {}; - - if (this.passwordPolicy.minimumUppercaseLetters > 0 && - !new RegExp(`(?:.*?[A-Z]){${this.passwordPolicy.minimumUppercaseLetters}}`).test(value)) { - errors.notUpperCase = true; - } - - if (this.passwordPolicy.minimumLowercaseLetters > 0 && - !new RegExp(`(?:.*?[a-z]){${this.passwordPolicy.minimumLowercaseLetters}}`).test(value)) { - errors.notLowerCase = true; - } - - if (this.passwordPolicy.minimumDigits > 0 - && !new RegExp(`(?:.*?\\d){${this.passwordPolicy.minimumDigits}}`).test(value)) { - errors.notNumeric = true; - } - - if (this.passwordPolicy.minimumSpecialCharacters > 0 && - !new RegExp(`(?:.*?[\\W_]){${this.passwordPolicy.minimumSpecialCharacters}}`).test(value)) { - errors.notSpecial = true; - } - - if (!this.passwordPolicy.allowWhitespaces && /\s/.test(value)) { - errors.hasWhitespaces = true; - } - - if (this.passwordPolicy.minimumLength > 0 && value.length < this.passwordPolicy.minimumLength) { - errors.minLength = true; - } - - if (!value.length || this.passwordPolicy.maximumLength > 0 && value.length > this.passwordPolicy.maximumLength) { - errors.maxLength = true; - } + passwordNotSameAsOld(): ValidatorFn { + return (group: AbstractControl): ValidationErrors | null => { + const currentPassControl = group.get('currentPassword'); + const newPassControl = group.get('newPassword'); - return isEqual(errors, {}) ? null : errors; - }; - } - private samePasswordValidation(isSame: boolean, key: string): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - const value: string = control.value; - const keyValue = control.parent?.value[key]; + const current = currentPassControl?.value ?? ''; + const newPass = newPassControl?.value ?? ''; - if (isSame) { - return value === keyValue ? {samePassword: true} : null; + if (current && newPass && current === newPass) { + newPassControl?.setErrors({ + ...newPassControl.errors, + passwordSameAsOld: true + }); + return { passwordSameAsOld: true }; + } else { + const currentErrors = newPassControl?.errors; + if (currentErrors?.passwordSameAsOld) { + const { passwordSameAsOld, ...rest } = currentErrors; + newPassControl.setErrors(Object.keys(rest).length ? rest : null); + } + return null; } - return value !== keyValue ? {differencePassword: true} : null; }; } diff --git a/ui-ngx/src/app/modules/login/login-routing.module.ts b/ui-ngx/src/app/modules/login/login-routing.module.ts index 3000d81fb8..da4ac05def 100644 --- a/ui-ngx/src/app/modules/login/login-routing.module.ts +++ b/ui-ngx/src/app/modules/login/login-routing.module.ts @@ -14,8 +14,8 @@ /// limitations under the License. /// -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { inject, NgModule } from '@angular/core'; +import { ActivatedRouteSnapshot, ResolveFn, Router, RouterModule, RouterStateSnapshot, Routes } from '@angular/router'; import { LoginComponent } from './pages/login/login.component'; import { AuthGuard } from '@core/guards/auth.guard'; @@ -25,6 +25,21 @@ import { CreatePasswordComponent } from '@modules/login/pages/login/create-passw import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component'; import { Authority } from '@shared/models/authority.enum'; import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { AuthService } from '@core/auth/auth.service'; +import { UserPasswordPolicy } from '@shared/models/settings.models'; + +const passwordPolicyResolver: ResolveFn = (route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + router = inject(Router), + authService = inject(AuthService)) => { + return authService.getUserPasswordPolicy().pipe( + catchError(() => { + return of({} as UserPasswordPolicy); + }) + ); +}; const routes: Routes = [ { @@ -52,7 +67,10 @@ const routes: Routes = [ title: 'login.reset-password', module: 'public' }, - canActivate: [AuthGuard] + canActivate: [AuthGuard], + resolve: { + passwordPolicy: passwordPolicyResolver + } }, { path: 'login/resetExpiredPassword', @@ -62,7 +80,10 @@ const routes: Routes = [ module: 'public', expiredPassword: true }, - canActivate: [AuthGuard] + canActivate: [AuthGuard], + resolve: { + passwordPolicy: passwordPolicyResolver + } }, { path: 'login/createPassword', @@ -71,7 +92,10 @@ const routes: Routes = [ title: 'login.create-password', module: 'public' }, - canActivate: [AuthGuard] + canActivate: [AuthGuard], + resolve: { + passwordPolicy: passwordPolicyResolver + } }, { path: 'login/mfa', diff --git a/ui-ngx/src/app/modules/login/login.module.ts b/ui-ngx/src/app/modules/login/login.module.ts index 35dbfad7e2..a7d8731f9d 100644 --- a/ui-ngx/src/app/modules/login/login.module.ts +++ b/ui-ngx/src/app/modules/login/login.module.ts @@ -25,6 +25,7 @@ import { ResetPasswordComponent } from '@modules/login/pages/login/reset-passwor import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component'; import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component'; import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component'; +import { PasswordRequirementsTooltipComponent } from '@modules/login/pages/login/password-requirements-tooltip.component'; @NgModule({ declarations: [ @@ -33,7 +34,8 @@ import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.co ResetPasswordComponent, CreatePasswordComponent, TwoFactorAuthLoginComponent, - LinkExpiredComponent + LinkExpiredComponent, + PasswordRequirementsTooltipComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.html b/ui-ngx/src/app/modules/login/pages/login/create-password.component.html index e5b287df16..79b601d099 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.html @@ -32,15 +32,28 @@ common.password - + lock + + {{ 'security.password-requirement.password-not-meet-requirements' | translate }} + login.password-again - + lock + + {{ 'security.password-requirement.new-passwords-not-match' | translate }} +
+ + diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts index 4cb8fa7cba..e7aa68cba0 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts @@ -14,57 +14,71 @@ /// limitations under the License. /// -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { AuthService } from '@core/auth/auth.service'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; -import { UntypedFormBuilder } from '@angular/forms'; -import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; import { ActivatedRoute } from '@angular/router'; -import { Subscription } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { UserPasswordPolicy } from '@shared/models/settings.models'; +import { combineLatest } from 'rxjs'; +import { + passwordsMatchValidator, + passwordStrengthValidator +} from '@shared/models/password.models'; @Component({ selector: 'tb-create-password', templateUrl: './create-password.component.html', styleUrls: ['./create-password.component.scss'] }) -export class CreatePasswordComponent extends PageComponent implements OnInit, OnDestroy { +export class CreatePasswordComponent extends PageComponent { activateToken = ''; - sub: Subscription; - - createPassword = this.fb.group({ - password: [''], - password2: [''] - }); + createPassword: UntypedFormGroup; + passwordPolicy: UserPasswordPolicy; constructor(protected store: Store, private route: ActivatedRoute, private authService: AuthService, private translate: TranslateService, - public fb: UntypedFormBuilder) { + private fb: UntypedFormBuilder) { super(store); - } - ngOnInit() { - this.sub = this.route - .queryParams - .subscribe(params => { - this.activateToken = params.activateToken || ''; + combineLatest([ + this.route.queryParams, + this.route.data + ]) + .pipe(takeUntilDestroyed()) + .subscribe(([params, data]) => { + this.activateToken = params['activateToken'] || ''; + this.passwordPolicy = data['passwordPolicy']; }); + + this.buildCreatePasswordForm(); + } + + private buildCreatePasswordForm() { + this.createPassword = this.fb.group({ + newPassword: ['', [Validators.required, passwordStrengthValidator(this.passwordPolicy)]], + newPassword2:[''] + }, { + validators: [ + passwordsMatchValidator('newPassword', 'newPassword2'), + ] + }); } - ngOnDestroy(): void { - super.ngOnDestroy(); - this.sub.unsubscribe(); + get passwordErrorsLength(): number { + return Object.keys(this.createPassword.get('newPassword')?.errors ?? {}).length; } onCreatePassword() { - if (this.createPassword.get('password').value !== this.createPassword.get('password2').value) { - this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'), - type: 'error' })); + if (this.createPassword.invalid) { + this.createPassword.markAllAsTouched(); } else { this.authService.activate( this.activateToken, diff --git a/ui-ngx/src/app/modules/login/pages/login/password-requirements-tooltip.component.html b/ui-ngx/src/app/modules/login/pages/login/password-requirements-tooltip.component.html new file mode 100644 index 0000000000..f54b8c64ef --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/password-requirements-tooltip.component.html @@ -0,0 +1,35 @@ + + +
+ @for (rule of passwordErrorRules; track $index) { + @if (!rule.policyProp || passwordPolicy[rule.policyProp] > 0) { +

+ + {{ checkForError(rule.key) ? 'mdi:close' : 'mdi:check' }} + + {{ rule.translation | translate : passwordPolicy }} +

+ } + } +
+
+
diff --git a/ui-ngx/src/app/modules/login/pages/login/password-requirements-tooltip.component.scss b/ui-ngx/src/app/modules/login/pages/login/password-requirements-tooltip.component.scss new file mode 100644 index 0000000000..10ed07030d --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/password-requirements-tooltip.component.scss @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.password-checklist-card { + background-color: #0000009E; + backdrop-filter: blur(8px); + color: white; + padding: 12px 16px; + border-radius: 8px; + position: relative; + min-width: 220px; + display: flex; + gap: 8px; + flex-direction: column; + + & > tb-icon { + color: white; + } + + & > p { + margin: 0; + } + + & > .tooltip-arrow { + position: absolute; + bottom: -6px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid #002b36; + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/password-requirements-tooltip.component.ts b/ui-ngx/src/app/modules/login/pages/login/password-requirements-tooltip.component.ts new file mode 100644 index 0000000000..9d8c4d9b66 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/password-requirements-tooltip.component.ts @@ -0,0 +1,57 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input } from '@angular/core'; +import { CdkOverlayOrigin, ConnectionPositionPair } from '@angular/cdk/overlay'; +import { passwordErrorRules } from '@shared/models/password.models'; +import { AbstractControl } from '@angular/forms'; +import { UserPasswordPolicy } from '@shared/models/settings.models'; + +@Component({ + selector: 'tb-password-requirements-tooltip', + templateUrl: './password-requirements-tooltip.component.html', + styleUrl: './password-requirements-tooltip.component.scss' +}) +export class PasswordRequirementsTooltipComponent { + @Input() passwordControl: AbstractControl; + @Input() passwordPolicy: UserPasswordPolicy; + @Input() trigger: CdkOverlayOrigin; + + passwordErrorRules = passwordErrorRules; + isTooltipOpen = false; + + overlayPositions: ConnectionPositionPair[] = [ + { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -20 + } + ]; + + checkForError(errorName: string): boolean { + return this.passwordControl?.hasError(errorName) ?? false; + } + + onFocus(): void { + this.isTooltipOpen = true; + } + + onBlur(): void { + this.isTooltipOpen = false; + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html index 5233189902..b4be2ecc9d 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html @@ -35,15 +35,28 @@ login.new-password - + lock + + {{ 'security.password-requirement.password-not-meet-requirements' | translate }} + login.new-password-again lock + + {{ 'security.password-requirement.new-passwords-not-match' | translate }} +
+ + diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts index 202722cf15..4a2c0bc75d 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts @@ -14,61 +14,75 @@ /// limitations under the License. /// -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { AuthService } from '@core/auth/auth.service'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; -import { UntypedFormBuilder } from '@angular/forms'; -import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { Subscription } from 'rxjs'; +import { combineLatest } from 'rxjs'; +import { UserPasswordPolicy } from '@shared/models/settings.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + passwordsMatchValidator, + passwordStrengthValidator +} from '@shared/models/password.models'; @Component({ selector: 'tb-reset-password', templateUrl: './reset-password.component.html', styleUrls: ['./reset-password.component.scss'] }) -export class ResetPasswordComponent extends PageComponent implements OnInit, OnDestroy { +export class ResetPasswordComponent extends PageComponent { isExpiredPassword: boolean; resetToken = ''; - sub: Subscription; - resetPassword = this.fb.group({ - newPassword: [''], - newPassword2: [''] - }); + resetPassword: UntypedFormGroup; + passwordPolicy: UserPasswordPolicy; constructor(protected store: Store, private route: ActivatedRoute, private router: Router, private authService: AuthService, private translate: TranslateService, - public fb: UntypedFormBuilder) { + private fb: UntypedFormBuilder) { super(store); + combineLatest([ + this.route.queryParams, + this.route.data + ]) + .pipe(takeUntilDestroyed()) + .subscribe(([params, data]) => { + this.resetToken = params['resetToken'] || ''; + this.passwordPolicy = data['passwordPolicy']; + this.isExpiredPassword = data['expiredPassword'] ?? false; + }); + + this.buildResetPasswordForm(); } - ngOnInit() { - this.isExpiredPassword = this.route.snapshot.data.expiredPassword; - this.sub = this.route - .queryParams - .subscribe(params => { - this.resetToken = params.resetToken || ''; - }); + private buildResetPasswordForm() { + this.resetPassword = this.fb.group({ + newPassword: ['', [Validators.required, passwordStrengthValidator(this.passwordPolicy)]], + newPassword2: [''] + }, { + validators: [ + passwordsMatchValidator('newPassword', 'newPassword2'), + ] + }); } - ngOnDestroy(): void { - super.ngOnDestroy(); - this.sub.unsubscribe(); + get passwordErrorsLength(): number { + return Object.keys(this.resetPassword.get('newPassword')?.errors ?? {}).length; } onResetPassword() { - if (this.resetPassword.get('newPassword').value !== this.resetPassword.get('newPassword2').value) { - this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'), - type: 'error' })); + if (this.resetPassword.invalid) { + this.resetPassword.markAllAsTouched(); } else { this.authService.resetPassword( this.resetToken, diff --git a/ui-ngx/src/app/shared/models/password.models.ts b/ui-ngx/src/app/shared/models/password.models.ts new file mode 100644 index 0000000000..4bba3d6737 --- /dev/null +++ b/ui-ngx/src/app/shared/models/password.models.ts @@ -0,0 +1,122 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { UserPasswordPolicy } from '@shared/models/settings.models'; +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { isEqual } from '@core/utils'; + +export enum PasswordErrorMessageKey { + minLength = 'security.password-requirement.password-min-length', + maxLength = 'security.password-requirement.password-max-length', + notUpperCase = 'security.password-requirement.password-uppercase', + notLowerCase = 'security.password-requirement.password-lowercase', + notNumeric = 'security.password-requirement.password-digit', + notSpecial = 'security.password-requirement.password-special-characters', + hasWhitespaces = 'security.password-requirement.password-should-not-contain-spaces', + default = 'security.password-requirement.password-not-meet-requirements' +} + +export enum TooltipPasswordErrorMessageKey { + minLength = 'security.password-requirement.password-tooltip-min-length', + maxLength = 'security.password-requirement.password-tooltip-max-length', + notUpperCase = 'security.password-requirement.password-tooltip-uppercase', + notLowerCase = 'security.password-requirement.password-tooltip-lowercase', + notNumeric = 'security.password-requirement.password-tooltip-digit', + notSpecial = 'security.password-requirement.password-tooltip-special-characters' +} + +export const passwordErrorRules = [ + { key: 'minLength', policyProp: 'minimumLength', translation: TooltipPasswordErrorMessageKey.minLength }, + { key: 'notUpperCase', policyProp: 'minimumUppercaseLetters', translation: TooltipPasswordErrorMessageKey.notUpperCase }, + { key: 'notLowerCase', policyProp: 'minimumLowercaseLetters', translation: TooltipPasswordErrorMessageKey.notLowerCase }, + { key: 'notNumeric', policyProp: 'minimumDigits', translation: TooltipPasswordErrorMessageKey.notNumeric }, + { key: 'notSpecial', policyProp: 'minimumSpecialCharacters', translation: TooltipPasswordErrorMessageKey.notSpecial }, + { key: 'maxLength', policyProp: 'maximumLength', translation: TooltipPasswordErrorMessageKey.maxLength }, +]; + +export const passwordsMatchValidator = (firstControlName: string, secondControlName: string): ValidatorFn =>{ + return (group: AbstractControl): ValidationErrors | null => { + const newPassControl = group.get(firstControlName); + const confirmControl = group.get(secondControlName); + + if (!newPassControl || !confirmControl) { + return null; + } + + const newPass = newPassControl.value ?? ''; + const confirm = confirmControl.value ?? ''; + + const userInteracted = + confirmControl.touched || confirmControl.dirty || group.touched; + + if (!userInteracted) { + return null; + } + + if (newPass && confirm !== newPass) { + confirmControl.setErrors({ passwordsNotMatch: true }); + return { passwordsNotMatch: true }; + } else { + const currentErrors = confirmControl?.errors; + if (currentErrors?.['passwordsNotMatch']) { + const { passwordsNotMatch, ...rest } = currentErrors; + confirmControl?.setErrors(Object.keys(rest).length ? rest : null); + } + return null; + } + }; +} + +export const passwordStrengthValidator = (passwordPolicy: UserPasswordPolicy): ValidatorFn => { + return (control: AbstractControl): ValidationErrors | null => { + const value: string = control.value; + const errors: any = {}; + + if (passwordPolicy.minimumUppercaseLetters > 0 && + !new RegExp(`(?:.*?[A-Z]){${passwordPolicy.minimumUppercaseLetters}}`).test(value)) { + errors.notUpperCase = true; + } + + if (passwordPolicy.minimumLowercaseLetters > 0 && + !new RegExp(`(?:.*?[a-z]){${passwordPolicy.minimumLowercaseLetters}}`).test(value)) { + errors.notLowerCase = true; + } + + if (passwordPolicy.minimumDigits > 0 + && !new RegExp(`(?:.*?\\d){${passwordPolicy.minimumDigits}}`).test(value)) { + errors.notNumeric = true; + } + + if (passwordPolicy.minimumSpecialCharacters > 0 && + !new RegExp(`(?:.*?[\\W_]){${passwordPolicy.minimumSpecialCharacters}}`).test(value)) { + errors.notSpecial = true; + } + + if (!passwordPolicy.allowWhitespaces && /\s/.test(value)) { + errors.hasWhitespaces = true; + } + + if (passwordPolicy.minimumLength > 0 && value.length < passwordPolicy.minimumLength) { + errors.minLength = true; + } + + if (!value.length || passwordPolicy.maximumLength > 0 && value.length > passwordPolicy.maximumLength) { + errors.maxLength = true; + } + + return isEqual(errors, {}) ? null : errors; + }; +} diff --git a/ui-ngx/src/app/shared/models/public-api.ts b/ui-ngx/src/app/shared/models/public-api.ts index bd7fe18262..5f930ff180 100644 --- a/ui-ngx/src/app/shared/models/public-api.ts +++ b/ui-ngx/src/app/shared/models/public-api.ts @@ -71,3 +71,4 @@ export * from './query/query.models'; export * from './regex.constants'; export * from './trendz-settings.models'; export * from './ai-model.models'; +export * from './password.models'; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index ec3ef79ef4..8f3ef7e3d8 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4412,6 +4412,18 @@ "at-least": "At least:", "character": "{ count, plural, =1 {1 character} other {# characters} }", "digit": "{ count, plural, =1 {1 digit} other {# digits} }", + "password-tooltip-min-length": "At least {{minimumLength}} characters long", + "password-tooltip-max-length": "At most {{maximumLength}} characters long", + "password-tooltip-uppercase": "{{minimumUppercaseLetters}} uppercase character", + "password-tooltip-lowercase": "{{minimumLowercaseLetters}} lowercase character", + "password-tooltip-digit": "{{minimumDigits}} number", + "password-tooltip-special-characters": "{{minimumSpecialCharacters}} special character", + "password-min-length": "Password must be {{minimumLength}} or more characters in length", + "password-max-length": "Password should be less than {{maximumLength}}", + "password-uppercase": "Password must contain {{minimumUppercaseLetters}} or more uppercase characters", + "password-lowercase": "Password must contain {{minimumLowercaseLetters}} or more lowercase characters", + "password-digit": "Password must contain {{minimumDigits}} or more digit characters", + "password-special-characters": "Password must contain {{minimumSpecialCharacters}} or more special characters", "incorrect-password-try-again": "Incorrect password. Try again", "lowercase-letter": "{ count, plural, =1 {1 lowercase letter} other {# lowercase letters} }", "new-passwords-not-match": "New password didn't match",