Browse Source

Merge pull request #14449 from vvlladd28/merge/ce

Merge PR
pull/14455/head
Vladyslav Prykhodko 6 months ago
committed by GitHub
parent
commit
38dc087bf1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      ui-ngx/src/app/core/auth/auth.service.ts
  2. 10
      ui-ngx/src/app/modules/home/pages/security/security.component.html
  3. 82
      ui-ngx/src/app/modules/home/pages/security/security.component.ts
  4. 34
      ui-ngx/src/app/modules/login/login-routing.module.ts
  5. 22
      ui-ngx/src/app/modules/login/pages/login/create-password.component.html
  6. 60
      ui-ngx/src/app/modules/login/pages/login/create-password.component.ts
  7. 20
      ui-ngx/src/app/modules/login/pages/login/reset-password.component.html
  8. 60
      ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts
  9. 35
      ui-ngx/src/app/shared/components/password-requirements-tooltip.component.html
  10. 48
      ui-ngx/src/app/shared/components/password-requirements-tooltip.component.scss
  11. 53
      ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts
  12. 106
      ui-ngx/src/app/shared/models/password.models.ts
  13. 1
      ui-ngx/src/app/shared/models/public-api.ts
  14. 11
      ui-ngx/src/app/shared/shared.module.ts
  15. 6
      ui-ngx/src/assets/locale/locale.constant-en_US.json

4
ui-ngx/src/app/core/auth/auth.service.ts

@ -168,8 +168,8 @@ export class AuthService {
));
}
public getUserPasswordPolicy() {
return this.http.get<UserPasswordPolicy>(`/api/noauth/userPasswordPolicy`, defaultHttpOptions());
public getUserPasswordPolicy(config?: RequestConfig) {
return this.http.get<UserPasswordPolicy>(`/api/noauth/userPasswordPolicy`, defaultHttpOptionsFromConfig(config));
}
public activateByEmailCode(emailCode: string): Observable<LoginResponse> {

10
ui-ngx/src/app/modules/home/pages/security/security.component.html

@ -54,7 +54,7 @@
<mat-label translate>profile.current-password</mat-label>
<input matInput type="password" name="current-password" formControlName="currentPassword" autocomplete="current-password"/>
<tb-toggle-password [class.!hidden]="!changePassword.get('currentPassword').dirty && !changePassword.get('currentPassword').touched" matSuffix></tb-toggle-password>
<mat-error *ngIf="changePassword.get('currentPassword').hasError('differencePassword')">
<mat-error *ngIf="changePassword.get('currentPassword').hasError('passwordsNotMatch')">
{{ 'security.password-requirement.incorrect-password-try-again' | translate }}
</mat-error>
</mat-form-field>
@ -66,13 +66,13 @@
<mat-error *ngIf="changePassword.get('newPassword').errors
&& !changePassword.get('newPassword').hasError('alreadyUsed')
&& !changePassword.get('newPassword').hasError('hasWhitespaces')
&& !changePassword.get('newPassword').hasError('samePassword')">
&& !changePassword.get('newPassword').hasError('passwordSameAsOld')">
{{ 'security.password-requirement.password-not-meet-requirements' | translate }}
</mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('alreadyUsed')">
{{ changePassword.get('newPassword').getError('alreadyUsed') }}
</mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('samePassword')">
<mat-error *ngIf="changePassword.get('newPassword').hasError('passwordSameAsOld')">
{{ 'security.password-requirement.password-should-difference' | translate }}
</mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('hasWhitespaces')">
@ -86,7 +86,7 @@
<mat-label translate>login.new-password-again</mat-label>
<input matInput type="password" name="new-password" formControlName="newPassword2" autocomplete="new-password" required/>
<tb-toggle-password [class.!hidden]="!changePassword.get('newPassword2').dirty && !changePassword.get('newPassword2').touched" matSuffix></tb-toggle-password>
<mat-error *ngIf="changePassword.get('newPassword2').hasError('differencePassword')">
<mat-error *ngIf="changePassword.get('newPassword2').hasError('passwordsNotMatch')">
{{ 'security.password-requirement.new-passwords-not-match' | translate }}
</mat-error>
</mat-form-field>
@ -148,7 +148,7 @@
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async)">
[disabled]="(isLoading$ | async) || (!changePassword.valid && changePassword.touched)">
{{ 'profile.change-password' | translate }}
</button>
</div>

82
ui-ngx/src/app/modules/home/pages/security/security.component.ts

@ -21,9 +21,9 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
AbstractControl,
FormGroupDirective,
UntypedFormBuilder,
UntypedFormGroup, FormGroupDirective,
NgForm,
UntypedFormGroup,
ValidationErrors,
ValidatorFn,
Validators
@ -48,7 +48,7 @@ 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';
@ -56,6 +56,7 @@ import {
ApiKeysTableDialogComponent,
ApiKeysTableDialogData
} from '@home/components/api-key/api-keys-table-dialog.component';
import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/models/password.models';
@Component({
selector: 'tb-security',
@ -168,7 +169,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'),
]
});
}
@ -176,64 +182,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 = {};
passwordNotSameAsOld(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const currentPassControl = group.get('currentPassword');
const newPassControl = group.get('newPassword');
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;
}
const current = currentPassControl?.value ?? '';
const newPass = newPassControl?.value ?? '';
if (!value.length || this.passwordPolicy.maximumLength > 0 && value.length > this.passwordPolicy.maximumLength) {
errors.maxLength = true;
}
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];
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;
};
}

34
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';
@ -26,6 +26,21 @@ import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-fact
import { Authority } from '@shared/models/authority.enum';
import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component';
import { ForceTwoFactorAuthLoginComponent } from '@modules/login/pages/login/force-two-factor-auth-login.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<UserPasswordPolicy> = (route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
router = inject(Router),
authService = inject(AuthService)) => {
return authService.getUserPasswordPolicy({ignoreErrors: true}).pipe(
catchError(() => {
return of({} as UserPasswordPolicy);
})
);
};
const routes: Routes = [
{
@ -53,7 +68,10 @@ const routes: Routes = [
title: 'login.reset-password',
module: 'public'
},
canActivate: [AuthGuard]
canActivate: [AuthGuard],
resolve: {
passwordPolicy: passwordPolicyResolver
}
},
{
path: 'login/resetExpiredPassword',
@ -63,7 +81,10 @@ const routes: Routes = [
module: 'public',
expiredPassword: true
},
canActivate: [AuthGuard]
canActivate: [AuthGuard],
resolve: {
passwordPolicy: passwordPolicyResolver
}
},
{
path: 'login/createPassword',
@ -72,7 +93,10 @@ const routes: Routes = [
title: 'login.create-password',
module: 'public'
},
canActivate: [AuthGuard]
canActivate: [AuthGuard],
resolve: {
passwordPolicy: passwordPolicyResolver
}
},
{
path: 'login/mfa',

22
ui-ngx/src/app/modules/login/pages/login/create-password.component.html

@ -32,15 +32,28 @@
<span style="height: 50px;"></span>
<mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>common.password</mat-label>
<input matInput type="password" autofocus formControlName="password"/>
<input matInput
type="password"
autofocus
cdkOverlayOrigin
#passwordTrigger="cdkOverlayOrigin"
(focus)="passwordTooltip.onFocus()"
(blur)="passwordTooltip.onBlur()"
formControlName="newPassword"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="this.createPassword.get('newPassword').errors">
{{ 'security.password-requirement.password-not-meet-requirements' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>login.password-again</mat-label>
<input matInput type="password" formControlName="password2"/>
<input matInput type="password" formControlName="newPassword2"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="createPassword.get('newPassword2').hasError('passwordsNotMatch')">
{{ 'security.password-requirement.new-passwords-not-match' | translate }}
</mat-error>
</mat-form-field>
<div class="flex flex-col items-center justify-start gap-4 gt-xs:flex-row gt-xs:items-start gt-xs:justify-center">
<button mat-raised-button color="accent" type="submit" [disabled]="(isLoading$ | async)">
@ -57,3 +70,8 @@
</mat-card-content>
</mat-card>
</div>
<tb-password-requirements-tooltip #passwordTooltip
[passwordControl]="createPassword.get('newPassword')"
[passwordPolicy]="passwordPolicy"
[trigger]="passwordTrigger">
</tb-password-requirements-tooltip>

60
ui-ngx/src/app/modules/login/pages/login/create-password.component.ts

@ -14,61 +14,55 @@
/// 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 { TranslateService } from '@ngx-translate/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { UserPasswordPolicy } from '@shared/models/settings.models';
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;
passwordPolicy: UserPasswordPolicy;
createPassword: FormGroup;
createPassword = this.fb.group({
password: [''],
password2: ['']
});
private activateToken: string;
constructor(protected store: Store<AppState>,
private route: ActivatedRoute,
constructor(private route: ActivatedRoute,
private authService: AuthService,
private translate: TranslateService,
public fb: UntypedFormBuilder) {
super(store);
}
private fb: FormBuilder) {
super();
this.activateToken = this.route.snapshot.queryParams['activateToken'] || '';
this.passwordPolicy = this.route.snapshot.data['passwordPolicy'];
ngOnInit() {
this.sub = this.route
.queryParams
.subscribe(params => {
this.activateToken = params.activateToken || '';
});
this.buildCreatePasswordForm();
}
ngOnDestroy(): void {
super.ngOnDestroy();
this.sub.unsubscribe();
private buildCreatePasswordForm() {
this.createPassword = this.fb.group({
newPassword: ['', [Validators.required, passwordStrengthValidator(this.passwordPolicy)]],
newPassword2: ['']
}, {
validators: [
passwordsMatchValidator('newPassword', 'newPassword2'),
]
});
}
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,
this.createPassword.get('password').value, true).subscribe();
this.createPassword.get('newPassword').value, true).subscribe();
}
}
}

20
ui-ngx/src/app/modules/login/pages/login/reset-password.component.html

@ -35,15 +35,28 @@
<span style="height: 50px;"></span>
<mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>login.new-password</mat-label>
<input matInput type="password" autofocus formControlName="newPassword"/>
<input matInput
type="password"
autofocus
cdkOverlayOrigin
#passwordTrigger="cdkOverlayOrigin"
(focus)="passwordTooltip.onFocus()"
(blur)="passwordTooltip.onBlur()"
formControlName="newPassword"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="this.resetPassword.get('newPassword').errors">
{{ 'security.password-requirement.password-not-meet-requirements' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>login.new-password-again</mat-label>
<input matInput type="password" formControlName="newPassword2"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="resetPassword.get('newPassword2').hasError('passwordsNotMatch')">
{{ 'security.password-requirement.new-passwords-not-match' | translate }}
</mat-error>
</mat-form-field>
<div class="flex flex-col items-center justify-start gap-4 gt-sm:flex-row gt-sm:items-start gt-sm:justify-center">
<button mat-raised-button color="accent" type="submit" [disabled]="(isLoading$ | async)">
@ -60,3 +73,8 @@
</mat-card-content>
</mat-card>
</div>
<tb-password-requirements-tooltip #passwordTooltip
[passwordControl]="resetPassword.get('newPassword')"
[passwordPolicy]="passwordPolicy"
[trigger]="passwordTrigger">
</tb-password-requirements-tooltip>

60
ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts

@ -14,61 +14,55 @@
/// 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 { TranslateService } from '@ngx-translate/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { UserPasswordPolicy } from '@shared/models/settings.models';
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: FormGroup;
passwordPolicy: UserPasswordPolicy;
resetPassword = this.fb.group({
newPassword: [''],
newPassword2: ['']
});
private resetToken: string;
constructor(protected store: Store<AppState>,
private route: ActivatedRoute,
constructor(private route: ActivatedRoute,
private router: Router,
private authService: AuthService,
private translate: TranslateService,
public fb: UntypedFormBuilder) {
super(store);
}
private fb: FormBuilder) {
super();
this.resetToken = this.route.snapshot.queryParams['resetToken'] || '';
this.passwordPolicy = this.route.snapshot.data['passwordPolicy'];
this.isExpiredPassword = this.route.snapshot.data['expiredPassword'] ?? false;
ngOnInit() {
this.isExpiredPassword = this.route.snapshot.data.expiredPassword;
this.sub = this.route
.queryParams
.subscribe(params => {
this.resetToken = params.resetToken || '';
});
this.buildResetPasswordForm();
}
ngOnDestroy(): void {
super.ngOnDestroy();
this.sub.unsubscribe();
private buildResetPasswordForm() {
this.resetPassword = this.fb.group({
newPassword: ['', [Validators.required, passwordStrengthValidator(this.passwordPolicy)]],
newPassword2: ['']
}, {
validators: [
passwordsMatchValidator('newPassword', 'newPassword2'),
]
});
}
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,

35
ui-ngx/src/app/shared/components/password-requirements-tooltip.component.html

@ -0,0 +1,35 @@
<!--
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.
-->
<ng-template cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isTooltipOpen"
[cdkConnectedOverlayPositions]="overlayPositions">
<div class="password-checklist-card">
@for (rule of passwordErrorRules; track $index) {
@if (!rule.policyProp || passwordPolicy[rule.policyProp] > 0) {
<p class="mat-body flex text-sm">
<tb-icon class="tb-mat-20">
{{ checkForError(rule.key) ? 'mdi:close' : 'mdi:check' }}
</tb-icon>
{{ rule.translation | translate : passwordPolicy }}
</p>
}
}
<div class="tooltip-arrow"></div>
</div>
</ng-template>

48
ui-ngx/src/app/shared/components/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;
}
}

53
ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts

@ -0,0 +1,53 @@
///
/// 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, ViewEncapsulation } 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';
import { POSITION_MAP } from '@shared/models/overlay.models';
@Component({
selector: 'tb-password-requirements-tooltip',
templateUrl: './password-requirements-tooltip.component.html',
styleUrl: './password-requirements-tooltip.component.scss',
encapsulation: ViewEncapsulation.None
})
export class PasswordRequirementsTooltipComponent {
@Input() passwordControl: AbstractControl;
@Input() passwordPolicy: UserPasswordPolicy;
@Input() trigger: CdkOverlayOrigin;
passwordErrorRules = passwordErrorRules;
isTooltipOpen = false;
overlayPositions: ConnectionPositionPair[] = [
{...POSITION_MAP.top, offsetY: -20}
];
checkForError(errorName: string): boolean {
return this.passwordControl?.hasError(errorName) ?? false;
}
onFocus(): void {
this.isTooltipOpen = true;
}
onBlur(): void {
this.isTooltipOpen = false;
}
}

106
ui-ngx/src/app/shared/models/password.models.ts

@ -0,0 +1,106 @@
///
/// 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 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',
hasWhitespaces = 'security.password-requirement.password-should-not-contain-spaces'
}
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 },
{ key: 'hasWhitespaces', policyProp: 'hasWhitespaces', translation: TooltipPasswordErrorMessageKey.hasWhitespaces },
];
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 ?? '';
if ((newPass || confirm) && 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;
};
}

1
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';

11
ui-ngx/src/app/shared/shared.module.ts

@ -220,14 +220,19 @@ import { CountryData } from '@shared/models/country.models';
import { SvgXmlComponent } from '@shared/components/svg-xml.component';
import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component';
import { AggregationTypeSelectComponent } from '@shared/components/time/aggregation/aggregation-type-select.component';
import { AggregationOptionsConfigPanelComponent } from '@shared/components/time/aggregation/aggregation-options-config-panel.component';
import {
AggregationOptionsConfigPanelComponent
} from '@shared/components/time/aggregation/aggregation-options-config-panel.component';
import { IntervalOptionsConfigPanelComponent } from '@shared/components/time/interval-options-config-panel.component';
import { GroupingIntervalOptionsComponent } from '@shared/components/time/aggregation/grouping-interval-options.component';
import {
GroupingIntervalOptionsComponent
} from '@shared/components/time/aggregation/grouping-interval-options.component';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row.component';
import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity-key-autocomplete.component';
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component';
import { PasswordRequirementsTooltipComponent } from '@shared/components/password-requirements-tooltip.component';
import { StringPatternAutocompleteComponent } from '@shared/components/string-pattern-autocomplete.component';
import { TimeUnitInputComponent } from '@shared/components/time-unit-input.component';
import { DateExpirationPipe } from '@shared/pipe/date-expiration.pipe';
@ -448,6 +453,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent,
MqttVersionSelectComponent,
PasswordRequirementsTooltipComponent,
TimeUnitInputComponent,
StringPatternAutocompleteComponent
],
@ -715,6 +721,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent,
MqttVersionSelectComponent,
PasswordRequirementsTooltipComponent,
TimeUnitInputComponent,
StringPatternAutocompleteComponent
]

6
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -4896,6 +4896,12 @@
"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",
"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",

Loading…
Cancel
Save