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() { public getUserPasswordPolicy(config?: RequestConfig) {
return this.http.get<UserPasswordPolicy>(`/api/noauth/userPasswordPolicy`, defaultHttpOptions()); return this.http.get<UserPasswordPolicy>(`/api/noauth/userPasswordPolicy`, defaultHttpOptionsFromConfig(config));
} }
public activateByEmailCode(emailCode: string): Observable<LoginResponse> { 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() { reset() {
if (this.initialAuditLogFilter) { if (this.initialAuditLogFilter) {
if (!auditLogFilterEquals(this.auditLogFilter, this.initialAuditLogFilter)) { if (!auditLogFilterEquals(this.auditLogFilterForm.value, this.initialAuditLogFilter)) {
this.updateAuditLogFilterForm(this.initialAuditLogFilter); this.updateAuditLogFilterForm(this.initialAuditLogFilter);
this.auditLogFilterForm.markAsDirty(); 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> <mat-label translate>profile.current-password</mat-label>
<input matInput type="password" name="current-password" formControlName="currentPassword" autocomplete="current-password"/> <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> <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 }} {{ 'security.password-requirement.incorrect-password-try-again' | translate }}
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@ -66,13 +66,13 @@
<mat-error *ngIf="changePassword.get('newPassword').errors <mat-error *ngIf="changePassword.get('newPassword').errors
&& !changePassword.get('newPassword').hasError('alreadyUsed') && !changePassword.get('newPassword').hasError('alreadyUsed')
&& !changePassword.get('newPassword').hasError('hasWhitespaces') && !changePassword.get('newPassword').hasError('hasWhitespaces')
&& !changePassword.get('newPassword').hasError('samePassword')"> && !changePassword.get('newPassword').hasError('passwordSameAsOld')">
{{ 'security.password-requirement.password-not-meet-requirements' | translate }} {{ 'security.password-requirement.password-not-meet-requirements' | translate }}
</mat-error> </mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('alreadyUsed')"> <mat-error *ngIf="changePassword.get('newPassword').hasError('alreadyUsed')">
{{ changePassword.get('newPassword').getError('alreadyUsed') }} {{ changePassword.get('newPassword').getError('alreadyUsed') }}
</mat-error> </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 }} {{ 'security.password-requirement.password-should-difference' | translate }}
</mat-error> </mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('hasWhitespaces')"> <mat-error *ngIf="changePassword.get('newPassword').hasError('hasWhitespaces')">
@ -86,7 +86,7 @@
<mat-label translate>login.new-password-again</mat-label> <mat-label translate>login.new-password-again</mat-label>
<input matInput type="password" name="new-password" formControlName="newPassword2" autocomplete="new-password" required/> <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> <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 }} {{ 'security.password-requirement.new-passwords-not-match' | translate }}
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@ -148,7 +148,7 @@
</button> </button>
<button mat-raised-button color="primary" <button mat-raised-button color="primary"
type="submit" type="submit"
[disabled]="(isLoading$ | async)"> [disabled]="(isLoading$ | async) || (!changePassword.valid && changePassword.touched)">
{{ 'profile.change-password' | translate }} {{ 'profile.change-password' | translate }}
</button> </button>
</div> </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 { AppState } from '@core/core.state';
import { import {
AbstractControl, AbstractControl,
FormGroupDirective,
UntypedFormBuilder, UntypedFormBuilder,
UntypedFormGroup, FormGroupDirective, UntypedFormGroup,
NgForm,
ValidationErrors, ValidationErrors,
ValidatorFn, ValidatorFn,
Validators Validators
@ -48,7 +48,7 @@ import {
import { authenticationDialogMap } from '@home/pages/security/authentication-dialog/authentication-dialog.map'; import { authenticationDialogMap } from '@home/pages/security/authentication-dialog/authentication-dialog.map';
import { takeUntil, tap } from 'rxjs/operators'; import { takeUntil, tap } from 'rxjs/operators';
import { Observable, of, Subject } from 'rxjs'; 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 { AuthService } from '@core/auth/auth.service';
import { UserPasswordPolicy } from '@shared/models/settings.models'; import { UserPasswordPolicy } from '@shared/models/settings.models';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatCheckboxChange } from '@angular/material/checkbox';
@ -56,6 +56,7 @@ import {
ApiKeysTableDialogComponent, ApiKeysTableDialogComponent,
ApiKeysTableDialogData ApiKeysTableDialogData
} from '@home/components/api-key/api-keys-table-dialog.component'; } from '@home/components/api-key/api-keys-table-dialog.component';
import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/models/password.models';
@Component({ @Component({
selector: 'tb-security', selector: 'tb-security',
@ -168,7 +169,12 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro
this.changePassword = this.fb.group({ this.changePassword = this.fb.group({
currentPassword: [''], currentPassword: [''],
newPassword: ['', Validators.required], 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.authService.getUserPasswordPolicy().subscribe(policy => {
this.passwordPolicy = policy; this.passwordPolicy = policy;
this.changePassword.get('newPassword').setValidators([ this.changePassword.get('newPassword').setValidators([
this.passwordStrengthValidator(), passwordStrengthValidator(this.passwordPolicy),
this.samePasswordValidation(true, 'currentPassword'),
Validators.required Validators.required
]); ]);
this.changePassword.get('newPassword').updateValueAndValidity({emitEvent: false}); this.changePassword.get('newPassword').updateValueAndValidity({emitEvent: false});
}); });
} }
private passwordStrengthValidator(): ValidatorFn { passwordNotSameAsOld(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => { return (group: AbstractControl): ValidationErrors | null => {
const value: string = control.value; const currentPassControl = group.get('currentPassword');
const errors: any = {}; 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 && const current = currentPassControl?.value ?? '';
!new RegExp(`(?:.*?[\\W_]){${this.passwordPolicy.minimumSpecialCharacters}}`).test(value)) { const newPass = newPassControl?.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) { if (current && newPass && current === newPass) {
errors.maxLength = true; newPassControl?.setErrors({
} ...newPassControl.errors,
passwordSameAsOld: true
return isEqual(errors, {}) ? null : errors; });
}; return { passwordSameAsOld: true };
} } else {
const currentErrors = newPassControl?.errors;
private samePasswordValidation(isSame: boolean, key: string): ValidatorFn { if (currentErrors?.passwordSameAsOld) {
return (control: AbstractControl): ValidationErrors | null => { const { passwordSameAsOld, ...rest } = currentErrors;
const value: string = control.value; newPassControl.setErrors(Object.keys(rest).length ? rest : null);
const keyValue = control.parent?.value[key]; }
return null;
if (isSame) {
return value === keyValue ? {samePassword: true} : 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. /// limitations under the License.
/// ///
import { NgModule } from '@angular/core'; import { inject, NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { ActivatedRouteSnapshot, ResolveFn, Router, RouterModule, RouterStateSnapshot, Routes } from '@angular/router';
import { LoginComponent } from './pages/login/login.component'; import { LoginComponent } from './pages/login/login.component';
import { AuthGuard } from '@core/guards/auth.guard'; 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 { Authority } from '@shared/models/authority.enum';
import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component'; import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component';
import { ForceTwoFactorAuthLoginComponent } from '@modules/login/pages/login/force-two-factor-auth-login.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 = [ const routes: Routes = [
{ {
@ -53,7 +68,10 @@ const routes: Routes = [
title: 'login.reset-password', title: 'login.reset-password',
module: 'public' module: 'public'
}, },
canActivate: [AuthGuard] canActivate: [AuthGuard],
resolve: {
passwordPolicy: passwordPolicyResolver
}
}, },
{ {
path: 'login/resetExpiredPassword', path: 'login/resetExpiredPassword',
@ -63,7 +81,10 @@ const routes: Routes = [
module: 'public', module: 'public',
expiredPassword: true expiredPassword: true
}, },
canActivate: [AuthGuard] canActivate: [AuthGuard],
resolve: {
passwordPolicy: passwordPolicyResolver
}
}, },
{ {
path: 'login/createPassword', path: 'login/createPassword',
@ -72,7 +93,10 @@ const routes: Routes = [
title: 'login.create-password', title: 'login.create-password',
module: 'public' module: 'public'
}, },
canActivate: [AuthGuard] canActivate: [AuthGuard],
resolve: {
passwordPolicy: passwordPolicyResolver
}
}, },
{ {
path: 'login/mfa', 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> <span style="height: 50px;"></span>
<mat-form-field class="mat-block tb-appearance-transparent"> <mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>common.password</mat-label> <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> <mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password> <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>
<mat-form-field class="mat-block tb-appearance-transparent"> <mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>login.password-again</mat-label> <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> <mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password> <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> </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"> <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)"> <button mat-raised-button color="accent" type="submit" [disabled]="(isLoading$ | async)">
@ -57,3 +70,8 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </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. /// limitations under the License.
/// ///
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component } from '@angular/core';
import { AuthService } from '@core/auth/auth.service'; 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 { PageComponent } from '@shared/components/page.component';
import { UntypedFormBuilder } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { TranslateService } from '@ngx-translate/core';
import { ActivatedRoute } from '@angular/router'; 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({ @Component({
selector: 'tb-create-password', selector: 'tb-create-password',
templateUrl: './create-password.component.html', templateUrl: './create-password.component.html',
styleUrls: ['./create-password.component.scss'] styleUrls: ['./create-password.component.scss']
}) })
export class CreatePasswordComponent extends PageComponent implements OnInit, OnDestroy { export class CreatePasswordComponent extends PageComponent {
activateToken = ''; passwordPolicy: UserPasswordPolicy;
sub: Subscription; createPassword: FormGroup;
createPassword = this.fb.group({ private activateToken: string;
password: [''],
password2: ['']
});
constructor(protected store: Store<AppState>, constructor(private route: ActivatedRoute,
private route: ActivatedRoute,
private authService: AuthService, private authService: AuthService,
private translate: TranslateService, private fb: FormBuilder) {
public fb: UntypedFormBuilder) { super();
super(store);
} this.activateToken = this.route.snapshot.queryParams['activateToken'] || '';
this.passwordPolicy = this.route.snapshot.data['passwordPolicy'];
ngOnInit() { this.buildCreatePasswordForm();
this.sub = this.route
.queryParams
.subscribe(params => {
this.activateToken = params.activateToken || '';
});
} }
ngOnDestroy(): void { private buildCreatePasswordForm() {
super.ngOnDestroy(); this.createPassword = this.fb.group({
this.sub.unsubscribe(); newPassword: ['', [Validators.required, passwordStrengthValidator(this.passwordPolicy)]],
newPassword2: ['']
}, {
validators: [
passwordsMatchValidator('newPassword', 'newPassword2'),
]
});
} }
onCreatePassword() { onCreatePassword() {
if (this.createPassword.get('password').value !== this.createPassword.get('password2').value) { if (this.createPassword.invalid) {
this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'), this.createPassword.markAllAsTouched();
type: 'error' }));
} else { } else {
this.authService.activate( this.authService.activate(
this.activateToken, 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> <span style="height: 50px;"></span>
<mat-form-field class="mat-block tb-appearance-transparent"> <mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>login.new-password</mat-label> <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> <mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password> <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>
<mat-form-field class="mat-block tb-appearance-transparent"> <mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>login.new-password-again</mat-label> <mat-label translate>login.new-password-again</mat-label>
<input matInput type="password" formControlName="newPassword2"/> <input matInput type="password" formControlName="newPassword2"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon> <mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password> <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> </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"> <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)"> <button mat-raised-button color="accent" type="submit" [disabled]="(isLoading$ | async)">
@ -60,3 +73,8 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </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. /// limitations under the License.
/// ///
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component } from '@angular/core';
import { AuthService } from '@core/auth/auth.service'; 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 { PageComponent } from '@shared/components/page.component';
import { UntypedFormBuilder } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { TranslateService } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router'; 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({ @Component({
selector: 'tb-reset-password', selector: 'tb-reset-password',
templateUrl: './reset-password.component.html', templateUrl: './reset-password.component.html',
styleUrls: ['./reset-password.component.scss'] styleUrls: ['./reset-password.component.scss']
}) })
export class ResetPasswordComponent extends PageComponent implements OnInit, OnDestroy { export class ResetPasswordComponent extends PageComponent {
isExpiredPassword: boolean; isExpiredPassword: boolean;
resetToken = ''; resetPassword: FormGroup;
sub: Subscription; passwordPolicy: UserPasswordPolicy;
resetPassword = this.fb.group({ private resetToken: string;
newPassword: [''],
newPassword2: ['']
});
constructor(protected store: Store<AppState>, constructor(private route: ActivatedRoute,
private route: ActivatedRoute,
private router: Router, private router: Router,
private authService: AuthService, private authService: AuthService,
private translate: TranslateService, private fb: FormBuilder) {
public fb: UntypedFormBuilder) { super();
super(store);
} this.resetToken = this.route.snapshot.queryParams['resetToken'] || '';
this.passwordPolicy = this.route.snapshot.data['passwordPolicy'];
this.isExpiredPassword = this.route.snapshot.data['expiredPassword'] ?? false;
ngOnInit() { this.buildResetPasswordForm();
this.isExpiredPassword = this.route.snapshot.data.expiredPassword;
this.sub = this.route
.queryParams
.subscribe(params => {
this.resetToken = params.resetToken || '';
});
} }
ngOnDestroy(): void { private buildResetPasswordForm() {
super.ngOnDestroy(); this.resetPassword = this.fb.group({
this.sub.unsubscribe(); newPassword: ['', [Validators.required, passwordStrengthValidator(this.passwordPolicy)]],
newPassword2: ['']
}, {
validators: [
passwordsMatchValidator('newPassword', 'newPassword2'),
]
});
} }
onResetPassword() { onResetPassword() {
if (this.resetPassword.get('newPassword').value !== this.resetPassword.get('newPassword2').value) { if (this.resetPassword.invalid) {
this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'), this.resetPassword.markAllAsTouched();
type: 'error' }));
} else { } else {
this.authService.resetPassword( this.authService.resetPassword(
this.resetToken, 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 './regex.constants';
export * from './trendz-settings.models'; export * from './trendz-settings.models';
export * from './ai-model.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 { SvgXmlComponent } from '@shared/components/svg-xml.component';
import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component'; import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component';
import { AggregationTypeSelectComponent } from '@shared/components/time/aggregation/aggregation-type-select.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 { 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 { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row.component'; import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row.component';
import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity-key-autocomplete.component'; import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity-key-autocomplete.component';
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component'; 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 { StringPatternAutocompleteComponent } from '@shared/components/string-pattern-autocomplete.component';
import { TimeUnitInputComponent } from '@shared/components/time-unit-input.component'; import { TimeUnitInputComponent } from '@shared/components/time-unit-input.component';
import { DateExpirationPipe } from '@shared/pipe/date-expiration.pipe'; import { DateExpirationPipe } from '@shared/pipe/date-expiration.pipe';
@ -448,6 +453,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ScadaSymbolInputComponent, ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent, EntityKeyAutocompleteComponent,
MqttVersionSelectComponent, MqttVersionSelectComponent,
PasswordRequirementsTooltipComponent,
TimeUnitInputComponent, TimeUnitInputComponent,
StringPatternAutocompleteComponent StringPatternAutocompleteComponent
], ],
@ -715,6 +721,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ScadaSymbolInputComponent, ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent, EntityKeyAutocompleteComponent,
MqttVersionSelectComponent, MqttVersionSelectComponent,
PasswordRequirementsTooltipComponent,
TimeUnitInputComponent, TimeUnitInputComponent,
StringPatternAutocompleteComponent StringPatternAutocompleteComponent
] ]

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

@ -4896,6 +4896,12 @@
"at-least": "At least:", "at-least": "At least:",
"character": "{ count, plural, =1 {1 character} other {# characters} }", "character": "{ count, plural, =1 {1 character} other {# characters} }",
"digit": "{ count, plural, =1 {1 digit} other {# digits} }", "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", "incorrect-password-try-again": "Incorrect password. Try again",
"lowercase-letter": "{ count, plural, =1 {1 lowercase letter} other {# lowercase letters} }", "lowercase-letter": "{ count, plural, =1 {1 lowercase letter} other {# lowercase letters} }",
"new-passwords-not-match": "New password didn't match", "new-passwords-not-match": "New password didn't match",

Loading…
Cancel
Save