Browse Source

Merge branch 'master' into entities-removal-sync

pull/14447/head
Mazurenko Nikita 10 months ago
committed by GitHub
parent
commit
7e3377cf77
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. 2
      ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts
  3. 10
      ui-ngx/src/app/modules/home/pages/security/security.component.html
  4. 82
      ui-ngx/src/app/modules/home/pages/security/security.component.ts
  5. 34
      ui-ngx/src/app/modules/login/login-routing.module.ts
  6. 22
      ui-ngx/src/app/modules/login/pages/login/create-password.component.html
  7. 60
      ui-ngx/src/app/modules/login/pages/login/create-password.component.ts
  8. 20
      ui-ngx/src/app/modules/login/pages/login/reset-password.component.html
  9. 60
      ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts
  10. 35
      ui-ngx/src/app/shared/components/password-requirements-tooltip.component.html
  11. 48
      ui-ngx/src/app/shared/components/password-requirements-tooltip.component.scss
  12. 53
      ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts
  13. 106
      ui-ngx/src/app/shared/models/password.models.ts
  14. 1
      ui-ngx/src/app/shared/models/public-api.ts
  15. 11
      ui-ngx/src/app/shared/shared.module.ts
  16. 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> {

2
ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts

@ -162,7 +162,7 @@ export class AuditLogFilterComponent implements OnInit, ControlValueAccessor {
reset() {
if (this.initialAuditLogFilter) {
if (!auditLogFilterEquals(this.auditLogFilter, this.initialAuditLogFilter)) {
if (!auditLogFilterEquals(this.auditLogFilterForm.value, this.initialAuditLogFilter)) {
this.updateAuditLogFilterForm(this.initialAuditLogFilter);
this.auditLogFilterForm.markAsDirty();
}

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