Browse Source

UI: Add support backup codes in 2FA

pull/6235/head
Vladyslav_Prykhodko 4 years ago
parent
commit
7af89eefb0
  1. 10
      ui-ngx/src/app/core/http/two-factor-authentication.service.ts
  2. 2
      ui-ngx/src/app/modules/home/components/import-export/import-dialog-csv.component.html
  3. 5
      ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts
  4. 21
      ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts
  5. 17
      ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html
  6. 27
      ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts
  7. 24
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss
  8. 6
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts
  9. 46
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html
  10. 65
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.ts
  11. 6
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html
  12. 16
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts
  13. 6
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html
  14. 16
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts
  15. 4
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html
  16. 12
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts
  17. 8
      ui-ngx/src/app/modules/home/pages/security/security.component.html
  18. 34
      ui-ngx/src/app/modules/home/pages/security/security.component.scss
  19. 66
      ui-ngx/src/app/modules/home/pages/security/security.component.ts
  20. 6
      ui-ngx/src/app/modules/home/pages/security/security.module.ts
  21. 12
      ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html
  22. 35
      ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts
  23. 53
      ui-ngx/src/app/shared/models/two-factor-auth.models.ts
  24. 6
      ui-ngx/src/assets/locale/locale.constant-cs_CZ.json
  25. 6
      ui-ngx/src/assets/locale/locale.constant-el_GR.json
  26. 26
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  27. 6
      ui-ngx/src/assets/locale/locale.constant-es_ES.json
  28. 6
      ui-ngx/src/assets/locale/locale.constant-ka_GE.json
  29. 6
      ui-ngx/src/assets/locale/locale.constant-ko_KR.json
  30. 6
      ui-ngx/src/assets/locale/locale.constant-lv_LV.json
  31. 6
      ui-ngx/src/assets/locale/locale.constant-pt_BR.json
  32. 6
      ui-ngx/src/assets/locale/locale.constant-ro_RO.json
  33. 6
      ui-ngx/src/assets/locale/locale.constant-ru_RU.json
  34. 6
      ui-ngx/src/assets/locale/locale.constant-sl_SI.json
  35. 6
      ui-ngx/src/assets/locale/locale.constant-tr_TR.json
  36. 6
      ui-ngx/src/assets/locale/locale.constant-uk_UA.json

10
ui-ngx/src/app/core/http/two-factor-authentication.service.ts

@ -24,6 +24,7 @@ import {
TwoFactorAuthProviderType,
TwoFactorAuthSettings
} from '@shared/models/two-factor-auth.models';
import { isDefinedAndNotNull } from '@core/utils';
@Injectable({
providedIn: 'root'
@ -66,10 +67,13 @@ export class TwoFactorAuthenticationService {
return this.http.post(`/api/2fa/account/config/submit`, authConfig, defaultHttpOptionsFromConfig(config));
}
verifyAndSaveTwoFaAccountConfig(authConfig: TwoFactorAuthAccountConfig, verificationCode: number,
verifyAndSaveTwoFaAccountConfig(authConfig: TwoFactorAuthAccountConfig, verificationCode?: number,
config?: RequestConfig): Observable<AccountTwoFaSettings> {
return this.http.post<AccountTwoFaSettings>(`/api/2fa/account/config?verificationCode=${verificationCode}`,
authConfig, defaultHttpOptionsFromConfig(config));
let url = '/api/2fa/account/config';
if (isDefinedAndNotNull(verificationCode)) {
url += `?verificationCode=${verificationCode}`;
}
return this.http.post<AccountTwoFaSettings>(url, authConfig, defaultHttpOptionsFromConfig(config));
}
deleteTwoFaAccountConfig(providerType: TwoFactorAuthProviderType, config?: RequestConfig): Observable<AccountTwoFaSettings> {

2
ui-ngx/src/app/modules/home/components/import-export/import-dialog-csv.component.html

@ -117,7 +117,7 @@
</mat-progress-bar>
</mat-step>
<mat-step>
<ng-template matStepLabel>{{ 'import.stepper-text.done' | translate }}</ng-template>
<ng-template matStepLabel>{{ 'action.done' | translate }}</ng-template>
<div fxLayout="column">
<p class="mat-body-1" *ngIf="this.statistical?.created">
{{ translate.instant('import.message.create-entities', {count: this.statistical.created}) }}

5
ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts

@ -138,6 +138,11 @@ export interface FileType {
extension: string;
}
export const TEXT_TYPE: FileType = {
mimeType: 'text/plain',
extension: 'txt'
};
export const JSON_TYPE: FileType = {
mimeType: 'text/json',
extension: 'json'

21
ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts

@ -35,7 +35,7 @@ import {
import { MatDialog } from '@angular/material/dialog';
import { ImportDialogComponent, ImportDialogData } from '@home/components/import-export/import-dialog.component';
import { forkJoin, Observable, of } from 'rxjs';
import {catchError, map, mergeMap, switchMap, tap} from 'rxjs/operators';
import { catchError, map, mergeMap, tap } from 'rxjs/operators';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
import { EntityService } from '@core/http/entity.service';
import { Widget, WidgetSize, WidgetType, WidgetTypeDetails } from '@shared/models/widget.models';
@ -44,7 +44,16 @@ import {
EntityAliasesDialogData
} from '@home/components/alias/entity-aliases-dialog.component';
import { ItemBufferService, WidgetItem } from '@core/services/item-buffer.service';
import { FileType, ImportWidgetResult, JSON_TYPE, WidgetsBundleItem, ZIP_TYPE, BulkImportRequest, BulkImportResult } from './import-export.models';
import {
BulkImportRequest,
BulkImportResult,
FileType,
ImportWidgetResult,
JSON_TYPE,
TEXT_TYPE,
WidgetsBundleItem,
ZIP_TYPE
} from './import-export.models';
import { EntityType } from '@shared/models/entity-type.models';
import { UtilsService } from '@core/services/utils.service';
import { WidgetService } from '@core/http/widget.service';
@ -554,6 +563,14 @@ export class ImportExportService {
);
}
public exportText(data: string | Array<string>, filename: string) {
let content = data;
if (Array.isArray(data)) {
content = data.join('\n');
}
this.downloadFile(content, filename, TEXT_TYPE);
}
public exportJSZip(data: object, filename: string) {
import('jszip').then((JSZip) => {
const jsZip = new JSZip.default();

17
ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html

@ -41,7 +41,7 @@
(click)="toggleExtensionPanel($event, i, provider.get('enable').value)"
formControlName="enable">
</mat-slide-toggle>
{{ provider.value.providerType }}
{{ twoFactorAuthProvidersData.get(provider.value.providerType).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
@ -94,6 +94,19 @@
</mat-error>
</mat-form-field>
</div>
<div *ngSwitchCase="twoFactorAuthProviderType.BACKUP_CODE">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.2fa.number-of-codes</mat-label>
<input matInput formControlName="codesQuantity" type="number" step="1" min="1" required>
<mat-error *ngIf="provider.get('codesQuantity').hasError('required')">
{{ "admin.2fa.number-of-codes-required" | translate }}
</mat-error>
<mat-error *ngIf="provider.get('codesQuantity').hasError('min') ||
provider.get('codesQuantity').hasError('pattern')">
{{ "admin.2fa.number-of-codes-pattern" | translate }}
</mat-error>
</mat-form-field>
</div>
</ng-container>
</ng-template>
</mat-expansion-panel>
@ -142,7 +155,7 @@
<mat-expansion-panel class="provider">
<mat-expansion-panel-header>
<mat-panel-title fxLayoutAlign="start center">
<mat-slide-toggle (click)="toggleExtensionPanel($event, 3, twoFaFormGroup.get('verificationCodeCheckRateLimitEnable').value)"
<mat-slide-toggle (click)="toggleExtensionPanel($event, providersForm.length, twoFaFormGroup.get('verificationCodeCheckRateLimitEnable').value)"
formControlName="verificationCodeCheckRateLimitEnable">
</mat-slide-toggle>
{{ 'admin.2fa.verification-code-check-rate-limit' | translate }}

27
ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { Component, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
import { Component, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { Store } from '@ngrx/store';
@ -22,6 +22,7 @@ import { AppState } from '@core/core.state';
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
import {
twoFactorAuthProvidersData,
TwoFactorAuthProviderType,
TwoFactorAuthSettings,
TwoFactorAuthSettingsForm
@ -29,7 +30,6 @@ import {
import { deepClone, isNotEmptyStr } from '@core/utils';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { MatStepper } from '@angular/material/stepper';
import { MatExpansionPanel } from '@angular/material/expansion';
@Component({
@ -40,9 +40,11 @@ import { MatExpansionPanel } from '@angular/material/expansion';
export class TwoFactorAuthSettingsComponent extends PageComponent implements OnInit, HasConfirmForm, OnDestroy {
private readonly destroy$ = new Subject<void>();
private readonly posIntValidation = [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)];
twoFaFormGroup: FormGroup;
twoFactorAuthProviderType = TwoFactorAuthProviderType;
twoFactorAuthProvidersData = twoFactorAuthProvidersData;
@ViewChildren(MatExpansionPanel) expansionPanel: QueryList<MatExpansionPanel>;
@ -123,8 +125,8 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
Validators.pattern(/^\d*$/)
]],
verificationCodeCheckRateLimitEnable: [false],
verificationCodeCheckRateLimitNumber: ['3', [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]],
verificationCodeCheckRateLimitTime: ['900', [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]],
verificationCodeCheckRateLimitNumber: ['3', this.posIntValidation],
verificationCodeCheckRateLimitTime: ['900', this.posIntValidation],
minVerificationCodeSendPeriod: ['30', [Validators.required, Validators.min(5), Validators.pattern(/^\d*$/)]],
providers: this.fb.array([])
});
@ -154,7 +156,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
providers: []
});
if (checkRateLimitNumber > 0) {
this.getByIndexPanel(3).open();
this.getByIndexPanel(this.providersForm.length).open();
}
Object.values(TwoFactorAuthProviderType).forEach((provider, index) => {
const findIndex = allowProvidersConfig.indexOf(provider);
@ -182,18 +184,13 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
Validators.required,
Validators.pattern(/\${code}/)
]];
formControlConfig.verificationCodeLifetime = [{value: 120, disabled: true}, [
Validators.required,
Validators.min(1),
Validators.pattern(/^\d*$/)
]];
formControlConfig.verificationCodeLifetime = [{value: 120, disabled: true}, this.posIntValidation];
break;
case TwoFactorAuthProviderType.EMAIL:
formControlConfig.verificationCodeLifetime = [{value: 120, disabled: true}, [
Validators.required,
Validators.min(1),
Validators.pattern(/^\d*$/)
]];
formControlConfig.verificationCodeLifetime = [{value: 120, disabled: true}, this.posIntValidation];
break;
case TwoFactorAuthProviderType.BACKUP_CODE:
formControlConfig.codesQuantity = [{value: 10, disabled: true}, this.posIntValidation];
break;
}
const newProviders = this.fb.group(formControlConfig);

24
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss

@ -30,6 +30,13 @@
&:not(:first-of-type) {
margin: 0 0 8px;
}
&.description {
margin: 0;
color: rgba(0, 0, 0, 0.54);
&:last-of-type {
margin-bottom: 24px;
}
}
}
.input-container {
@ -69,6 +76,23 @@
margin-bottom: 0;
}
.backup-code {
max-width: 500px;
.container {
max-width: 500px;
margin: 40px 0 24px;
.code {
letter-spacing: 0.25px;
padding: 0 30px;
margin-bottom: 16px;
font-family: Roboto Mono, "Helvetica Neue", monospace;
&.even {
text-align: right;
}
}
}
}
& ::ng-deep {
.mat-horizontal-stepper-header{
pointer-events: none !important;

6
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts

@ -19,11 +19,15 @@ import { TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models
import { TotpAuthDialogComponent } from './totp-auth-dialog.component';
import { SMSAuthDialogComponent } from './sms-auth-dialog.component';
import { EmailAuthDialogComponent } from './email-auth-dialog.component';
import {
BackupCodeAuthDialogComponent
} from '@home/pages/security/authentication-dialog/backup-code-auth-dialog.component';
export const authenticationDialogMap = new Map<TwoFactorAuthProviderType, Type<any>>(
[
[TwoFactorAuthProviderType.TOTP, TotpAuthDialogComponent],
[TwoFactorAuthProviderType.SMS, SMSAuthDialogComponent],
[TwoFactorAuthProviderType.EMAIL, EmailAuthDialogComponent]
[TwoFactorAuthProviderType.EMAIL, EmailAuthDialogComponent],
[TwoFactorAuthProviderType.BACKUP_CODE, BackupCodeAuthDialogComponent]
]
);

46
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html

@ -0,0 +1,46 @@
<!--
Copyright © 2016-2022 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.
-->
<mat-toolbar fxLayout="row" color="primary">
<h2 translate>security.2fa.dialog.get-backup-code-title</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="closeDialog()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content tb-toast class="backup-code">
<p class="mat-body-1 description" translate>security.2fa.dialog.backup-code-description</p>
<div class="container" fxLayout="row wrap">
<div *ngFor="let code of backupCode?.codes; even as $even" fxFlex="50" class="code" [ngClass]="{'even': $even}">
{{ code }}
</div>
</div>
<p class="mat-body-1 description" translate>security.2fa.dialog.backup-code-warn</p>
<div fxLayout="row" fxLayoutAlign="center start" fxLayoutGap="16px">
<button type="button" mat-stroked-button color="primary" (click)="downloadFile()">
{{ 'security.2fa.dialog.download-txt' | translate }}
</button>
<button type="button" mat-raised-button color="primary">
{{ 'action.print' | translate }}
</button>
</div>
</div>

65
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.ts

@ -0,0 +1,65 @@
///
/// Copyright © 2016-2022 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 } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
import { MatDialogRef } from '@angular/material/dialog';
import { FormBuilder } from '@angular/forms';
import {
AccountTwoFaSettings,
BackupCodeTwoFactorAuthAccountConfig,
TwoFactorAuthProviderType
} from '@shared/models/two-factor-auth.models';
import { mergeMap, tap } from 'rxjs/operators';
import { ImportExportService } from '@home/components/import-export/import-export.service';
@Component({
selector: 'tb-backup-code-auth-dialog',
templateUrl: './backup-code-auth-dialog.component.html',
styleUrls: ['./authentication-dialog.component.scss']
})
export class BackupCodeAuthDialogComponent extends DialogComponent<BackupCodeAuthDialogComponent> {
private config: AccountTwoFaSettings;
backupCode: BackupCodeTwoFactorAuthAccountConfig;
constructor(protected store: Store<AppState>,
protected router: Router,
private twoFaService: TwoFactorAuthenticationService,
private importExportService: ImportExportService,
public dialogRef: MatDialogRef<BackupCodeAuthDialogComponent>,
public fb: FormBuilder) {
super(store, router, dialogRef);
this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.BACKUP_CODE).pipe(
tap((data: BackupCodeTwoFactorAuthAccountConfig) => this.backupCode = data),
mergeMap(data => this.twoFaService.verifyAndSaveTwoFaAccountConfig(data, null, {ignoreLoading: true}))
).subscribe((config) => {
this.config = config;
});
}
closeDialog() {
this.dialogRef.close(this.config);
}
downloadFile() {
this.importExportService.exportText(this.backupCode.codes, 'backup-codes');
}
}

6
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html

@ -80,7 +80,7 @@
type="submit"
color="primary"
[disabled]="(isLoading$ | async) || emailVerificationForm.invalid">
{{ 'security.2fa.dialog.activate' | translate }}
{{ 'action.activate' | translate }}
</button>
</div>
</form>
@ -94,8 +94,8 @@
<button mat-raised-button
type="button"
color="primary"
[mat-dialog-close]="true">
{{ 'import.stepper-text.done' | translate }}
(click)="closeDialog()">
{{ 'action.done' | translate }}
</button>
</div>
</div>

16
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts

@ -22,7 +22,11 @@ import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
import { TwoFactorAuthAccountConfig, TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models';
import {
AccountTwoFaSettings,
TwoFactorAuthAccountConfig,
TwoFactorAuthProviderType
} from '@shared/models/two-factor-auth.models';
import { MatStepper } from '@angular/material/stepper';
export interface EmailAuthDialogData {
@ -37,6 +41,7 @@ export interface EmailAuthDialogData {
export class EmailAuthDialogComponent extends DialogComponent<EmailAuthDialogComponent> {
private authAccountConfig: TwoFactorAuthAccountConfig;
private config: AccountTwoFaSettings;
emailConfigForm: FormGroup;
emailVerificationForm: FormGroup;
@ -84,9 +89,10 @@ export class EmailAuthDialogComponent extends DialogComponent<EmailAuthDialogCom
case 1:
if (this.emailVerificationForm.valid) {
this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig,
this.emailVerificationForm.get('verificationCode').value).subscribe(() => {
this.stepper.next();
});
this.emailVerificationForm.get('verificationCode').value).subscribe((config) => {
this.config = config;
this.stepper.next();
});
} else {
this.showFormErrors(this.emailVerificationForm);
}
@ -95,7 +101,7 @@ export class EmailAuthDialogComponent extends DialogComponent<EmailAuthDialogCom
}
closeDialog() {
return this.dialogRef.close(this.stepper.selectedIndex > 1);
return this.dialogRef.close(this.config);
}
private showFormErrors(form: FormGroup) {

6
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html

@ -82,7 +82,7 @@
type="submit"
color="primary"
[disabled]="(isLoading$ | async) || smsVerificationForm.invalid">
{{ 'security.2fa.dialog.activate' | translate }}
{{ 'action.activate' | translate }}
</button>
</div>
</form>
@ -96,8 +96,8 @@
<button mat-raised-button
type="button"
color="primary"
[mat-dialog-close]="true">
{{ 'import.stepper-text.done' | translate }}
(click)="closeDialog()">
{{ 'action.done' | translate }}
</button>
</div>
</div>

16
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts

@ -22,7 +22,11 @@ import { Router } from '@angular/router';
import { MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
import { TwoFactorAuthAccountConfig, TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models';
import {
AccountTwoFaSettings,
TwoFactorAuthAccountConfig,
TwoFactorAuthProviderType
} from '@shared/models/two-factor-auth.models';
import { phoneNumberPattern } from '@shared/models/settings.models';
import { MatStepper } from '@angular/material/stepper';
@ -34,6 +38,7 @@ import { MatStepper } from '@angular/material/stepper';
export class SMSAuthDialogComponent extends DialogComponent<SMSAuthDialogComponent> {
private authAccountConfig: TwoFactorAuthAccountConfig;
private config: AccountTwoFaSettings;
phoneNumberPattern = phoneNumberPattern;
@ -82,9 +87,10 @@ export class SMSAuthDialogComponent extends DialogComponent<SMSAuthDialogCompone
case 1:
if (this.smsVerificationForm.valid) {
this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig,
this.smsVerificationForm.get('verificationCode').value).subscribe(() => {
this.stepper.next();
});
this.smsVerificationForm.get('verificationCode').value).subscribe((config) => {
this.config = config;
this.stepper.next();
});
} else {
this.showFormErrors(this.smsVerificationForm);
}
@ -93,7 +99,7 @@ export class SMSAuthDialogComponent extends DialogComponent<SMSAuthDialogCompone
}
closeDialog() {
return this.dialogRef.close(this.stepper.selectedIndex > 1);
return this.dialogRef.close(this.config);
}
private showFormErrors(form: FormGroup) {

4
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html

@ -84,8 +84,8 @@
<button mat-raised-button
type="button"
color="primary"
[mat-dialog-close]="true">
{{ 'import.stepper-text.done' | translate }}
(click)="closeDialog()">
{{ 'action.done' | translate }}
</button>
</div>
</div>

12
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts

@ -22,7 +22,11 @@ import { Router } from '@angular/router';
import { MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
import { TotpTwoFactorAuthAccountConfig, TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models';
import {
AccountTwoFaSettings,
TotpTwoFactorAuthAccountConfig,
TwoFactorAuthProviderType
} from '@shared/models/two-factor-auth.models';
import { MatStepper } from '@angular/material/stepper';
@Component({
@ -33,6 +37,7 @@ import { MatStepper } from '@angular/material/stepper';
export class TotpAuthDialogComponent extends DialogComponent<TotpAuthDialogComponent> {
private authAccountConfig: TotpTwoFactorAuthAccountConfig;
private config: AccountTwoFaSettings;
totpConfigForm: FormGroup;
totpAuthURL: string;
@ -69,7 +74,8 @@ export class TotpAuthDialogComponent extends DialogComponent<TotpAuthDialogCompo
onSaveConfig() {
if (this.totpConfigForm.valid) {
this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig,
this.totpConfigForm.get('verificationCode').value).subscribe(() => {
this.totpConfigForm.get('verificationCode').value).subscribe((config) => {
this.config = config;
this.stepper.next();
});
} else {
@ -81,7 +87,7 @@ export class TotpAuthDialogComponent extends DialogComponent<TotpAuthDialogCompo
}
closeDialog() {
return this.dialogRef.close(this.stepper.selectedIndex > 1);
return this.dialogRef.close(this.config);
}
}

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

@ -67,9 +67,15 @@
[checked]="useByDefault === provider"
(click)="changeDefaultProvider($event, provider)"
[disabled]="(isLoading$ | async) || activeSingleProvider"
*ngIf="twoFactorAuth.get(provider).value">
*ngIf="twoFactorAuth.get(provider).value && provider !== twoFactorAuthProviderType.BACKUP_CODE">
<span class="checkbox-label" translate>security.2fa.main-2fa-method</span>
</mat-checkbox>
<button type="button"
mat-stroked-button color="primary"
(click)="generateNewBackupCode()"
*ngIf="twoFactorAuth.get(provider).value && provider === twoFactorAuthProviderType.BACKUP_CODE">
Get new code
</button>
</div>
<mat-divider *ngIf="!$last"></mat-divider>
</ng-container>

34
ui-ngx/src/app/modules/home/pages/security/security.component.scss

@ -29,13 +29,6 @@
@media #{$mat-gt-xl} {
width: 45%;
}
.title-container {
margin: 0;
}
.profile-email {
font-size: 16px;
font-weight: 400;
}
.mat-subheader {
line-height: 24px;
color: rgba(0,0,0,0.54);
@ -52,22 +45,6 @@
opacity: 0.6;
padding: 8px 0;
}
.tb-home-dashboard {
tb-dashboard-autocomplete {
@media #{$mat-gt-sm} {
padding-right: 12px;
}
@media #{$mat-lt-md} {
padding-bottom: 12px;
}
}
mat-checkbox {
@media #{$mat-gt-sm} {
margin-top: 16px;
}
}
}
}
.description {
@ -76,13 +53,6 @@
margin-right: 8px;
}
.auth-title {
font-weight: 500;
line-height: 20px;
letter-spacing: 0.25px;
margin: 0;
}
.mat-divider-horizontal {
left: 16px;
right: 16px;
@ -111,5 +81,9 @@
.mat-checkbox {
margin-top: 8px;
}
.mat-stroked-button {
margin-top: 8px;
}
}
}

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

@ -35,7 +35,8 @@ import {
} from '@shared/models/two-factor-auth.models';
import { authenticationDialogMap } from '@home/pages/security/authentication-dialog/authentication-dialog.map';
import { takeUntil, tap } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { Observable, of, Subject } from 'rxjs';
import { isDefinedAndNotNull } from '@core/utils';
@Component({
selector: 'tb-security',
@ -45,11 +46,13 @@ import { Subject } from 'rxjs';
export class SecurityComponent extends PageComponent implements OnInit, OnDestroy {
private readonly destroy$ = new Subject<void>();
private accountConfig: AccountTwoFaSettings;
twoFactorAuth: FormGroup;
user: User;
allowTwoFactorProviders: TwoFactorAuthProviderType[] = [];
providersData = twoFactorAuthProvidersData;
twoFactorAuthProviderType = TwoFactorAuthProviderType;
useByDefault: TwoFactorAuthProviderType = null;
activeSingleProvider = true;
@ -94,13 +97,19 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro
this.twoFactorAuth = this.fb.group({
TOTP: [false],
SMS: [false],
EMAIL: [false]
EMAIL: [false],
BACKUP_CODE: [{value: false, disabled: true}]
});
this.twoFactorAuth.valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value: {TwoFactorAuthProviderType: boolean}) => {
const formActiveValue = Object.values(value).filter(item => item);
const formActiveValue = Object.keys(value).filter(item => value[item] && item !== TwoFactorAuthProviderType.BACKUP_CODE);
this.activeSingleProvider = formActiveValue.length < 2;
if (formActiveValue.length) {
this.twoFactorAuth.get('BACKUP_CODE').enable({emitEvent: false});
} else {
this.twoFactorAuth.get('BACKUP_CODE').disable({emitEvent: false});
}
});
}
@ -116,7 +125,8 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro
}
private processTwoFactorAuthConfig(setting: AccountTwoFaSettings) {
const configs = setting.configs;
this.accountConfig = setting;
const configs = this.accountConfig.configs;
Object.values(TwoFactorAuthProviderType).forEach(provider => {
if (configs[provider]) {
this.twoFactorAuth.get(provider).setValue(true);
@ -171,20 +181,23 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro
}
});
} else {
const dialogData = provider === TwoFactorAuthProviderType.EMAIL ? {email: this.user.email} : {};
this.dialog.open(authenticationDialogMap.get(provider), {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: dialogData
}).afterClosed().subscribe(res => {
if (res) {
this.twoFactorAuth.get(provider).setValue(res);
this.useByDefault = provider;
}
});
this.createdNewAuthConfig(provider);
}
}
private createdNewAuthConfig(provider: TwoFactorAuthProviderType) {
const dialogData = provider === TwoFactorAuthProviderType.EMAIL ? {email: this.user.email} : {};
this.dialog.open(authenticationDialogMap.get(provider), {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: dialogData
}).afterClosed().subscribe(res => {
if (isDefinedAndNotNull(res)) {
this.processTwoFactorAuthConfig(res);
}
});
}
changeDefaultProvider(event: MouseEvent, provider: TwoFactorAuthProviderType) {
event.stopPropagation();
event.preventDefault();
@ -195,4 +208,27 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro
.subscribe(data => this.processTwoFactorAuthConfig(data));
}
}
generateNewBackupCode() {
const codeLeft = this.accountConfig.configs[TwoFactorAuthProviderType.BACKUP_CODE].codesLeft;
let subscription: Observable<boolean>;
if (codeLeft) {
subscription = this.dialogService.confirm(
'Get new set of backup codes?',
`If you get new backup codes, ${codeLeft} remaining codes you have left will be unusable.`,
'',
'Get new codes'
);
} else {
subscription = of(true);
}
subscription.subscribe(res => {
if (res) {
this.twoFactorAuth.disable({emitEvent: false});
this.twoFaService.deleteTwoFaAccountConfig(TwoFactorAuthProviderType.BACKUP_CODE)
.pipe(tap(() => this.twoFactorAuth.enable({emitEvent: false})))
.subscribe(() => this.createdNewAuthConfig(TwoFactorAuthProviderType.BACKUP_CODE));
}
});
}
}

6
ui-ngx/src/app/modules/home/pages/security/security.module.ts

@ -22,13 +22,17 @@ import { SecurityRoutingModule } from './security-routing.module';
import { TotpAuthDialogComponent } from './authentication-dialog/totp-auth-dialog.component';
import { SMSAuthDialogComponent } from '@home/pages/security/authentication-dialog/sms-auth-dialog.component';
import { EmailAuthDialogComponent } from '@home/pages/security/authentication-dialog/email-auth-dialog.component';
import {
BackupCodeAuthDialogComponent
} from '@home/pages/security/authentication-dialog/backup-code-auth-dialog.component';
@NgModule({
declarations: [
SecurityComponent,
TotpAuthDialogComponent,
SMSAuthDialogComponent,
EmailAuthDialogComponent
EmailAuthDialogComponent,
BackupCodeAuthDialogComponent
],
imports: [
CommonModule,

12
ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html

@ -42,11 +42,11 @@
<mat-form-field class="mat-block" fxFlex floatLabel="always" hideRequiredMarker>
<mat-label></mat-label>
<input matInput formControlName="verificationCode"
required maxlength="6" type="text"
inputmode="numeric" pattern="[0-9]*"
required [maxlength]="maxLengthInput" type="text"
[attr.inputmode]="inputMode" [pattern]="pattern"
autocomplete="off"
placeholder="{{ 'security.2fa.dialog.verification-code' | translate }}"/>
<button *ngIf="selectedProvider !== twoFactorAuthProvider.TOTP"
placeholder="{{ providersData.get(selectedProvider).placeholder | translate }}"/>
<button *ngIf="showResendButton"
mat-button matSuffix
(click)="sendCode($event)"
[disabled]="disabledResendButton"
@ -56,10 +56,10 @@
<mat-error *ngIf="verificationForm.get('verificationCode').invalid" style="margin-bottom: 8px">
{{ 'security.2fa.dialog.verification-code-invalid' | translate }}
</mat-error>
<mat-error *ngIf="verificationForm.get('verificationCode').invalid && selectedProvider !== twoFactorAuthProvider.TOTP && countDownTime" class="timer">
<mat-error *ngIf="verificationForm.get('verificationCode').invalid && showResendButton && countDownTime" class="timer">
{{ 'login.resend-code-wait' | translate : {time: countDownTime} }}
</mat-error>
<mat-hint *ngIf="selectedProvider !== twoFactorAuthProvider.TOTP && countDownTime" class="timer">
<mat-hint *ngIf="showResendButton && countDownTime" class="timer">
{{ 'login.resend-code-wait' | translate : {time: countDownTime} }}
</mat-hint>
</mat-form-field>

35
ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts

@ -41,8 +41,8 @@ export class TwoFactorAuthLoginComponent extends PageComponent implements OnInit
private timer: Subscription;
private minVerificationPeriod = 0;
showResendButton = false;
selectedProvider: TwoFactorAuthProviderType;
twoFactorAuthProvider = TwoFactorAuthProviderType;
allowProviders: TwoFactorAuthProviderType[] = [];
providersData = twoFactorAuthProvidersLoginData;
@ -50,6 +50,10 @@ export class TwoFactorAuthLoginComponent extends PageComponent implements OnInit
disabledResendButton = true;
countDownTime = 0;
maxLengthInput = 6;
inputMode = 'numeric';
pattern = '[0-9]*';
verificationForm = this.fb.group({
verificationCode: ['', [
Validators.required,
@ -84,6 +88,7 @@ export class TwoFactorAuthLoginComponent extends PageComponent implements OnInit
});
if (this.selectedProvider !== TwoFactorAuthProviderType.TOTP) {
this.sendCode();
this.showResendButton = true;
}
this.timer = interval(1000).subscribe(() => this.updatedTime());
}
@ -104,16 +109,40 @@ export class TwoFactorAuthLoginComponent extends PageComponent implements OnInit
selectProvider(type: TwoFactorAuthProviderType) {
this.prevProvider = type === null ? this.selectedProvider : null;
this.selectedProvider = type;
this.showResendButton = false;
if (type !== null) {
this.verificationForm.get('verificationCode').reset();
const providerConfig = this.providersInfo.find(config => config.type === type);
this.providerDescription = this.translate.instant(this.providersData.get(providerConfig.type).description, {
contact: providerConfig.contact
});
this.minVerificationPeriod = providerConfig?.minVerificationCodeSendPeriod || 30;
if (type !== TwoFactorAuthProviderType.TOTP) {
if (type !== TwoFactorAuthProviderType.TOTP && type !== TwoFactorAuthProviderType.BACKUP_CODE) {
this.sendCode();
this.showResendButton = true;
this.minVerificationPeriod = providerConfig?.minVerificationCodeSendPeriod || 30;
}
if (type === TwoFactorAuthProviderType.BACKUP_CODE) {
this.verificationForm.get('verificationCode').setValidators([
Validators.required,
Validators.minLength(8),
Validators.maxLength(8),
Validators.pattern(/^[\dabcdef]*$/)
]);
this.maxLengthInput = 8;
this.inputMode = 'text';
this.pattern = '[0-9abcdef]*';
} else {
this.verificationForm.get('verificationCode').setValidators([
Validators.required,
Validators.minLength(6),
Validators.maxLength(6),
Validators.pattern(/^\d*$/)
]);
this.maxLengthInput = 6;
this.inputMode = 'numeric';
this.pattern = '[0-9]*';
}
this.verificationForm.get('verificationCode').updateValueAndValidity({emitEvent: false});
}
}

53
ui-ngx/src/app/shared/models/two-factor-auth.models.ts

@ -59,7 +59,8 @@ export interface TwoFactorAuthProviderFormConfig {
export enum TwoFactorAuthProviderType{
TOTP = 'TOTP',
SMS = 'SMS',
EMAIL = 'EMAIL'
EMAIL = 'EMAIL',
BACKUP_CODE = 'BACKUP_CODE'
}
interface GeneralTwoFactorAuthAccountConfig {
@ -79,7 +80,13 @@ export interface EmailTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAcc
email: string;
}
export type TwoFactorAuthAccountConfig = TotpTwoFactorAuthAccountConfig | SmsTwoFactorAuthAccountConfig | EmailTwoFactorAuthAccountConfig;
export interface BackupCodeTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig {
codesLeft: number;
codes?: Array<string>;
}
export type TwoFactorAuthAccountConfig = TotpTwoFactorAuthAccountConfig | SmsTwoFactorAuthAccountConfig |
EmailTwoFactorAuthAccountConfig | BackupCodeTwoFactorAuthAccountConfig;
export interface AccountTwoFaSettings {
@ -100,6 +107,7 @@ export interface TwoFactorAuthProviderData {
export interface TwoFactorAuthProviderLoginData extends TwoFactorAuthProviderData {
icon: string;
placeholder: string;
}
export const twoFactorAuthProvidersData = new Map<TwoFactorAuthProviderType, TwoFactorAuthProviderData>(
@ -122,6 +130,12 @@ export const twoFactorAuthProvidersData = new Map<TwoFactorAuthProviderType, Two
description: 'security.2fa.provider.email-description'
}
],
[
TwoFactorAuthProviderType.BACKUP_CODE, {
name: 'security.2fa.provider.backup_code',
description: 'security.2fa.provider.backup-code-description'
}
]
]
);
@ -129,24 +143,35 @@ export const twoFactorAuthProvidersLoginData = new Map<TwoFactorAuthProviderType
[
[
TwoFactorAuthProviderType.TOTP, {
name: 'security.2fa.provider.totp',
description: 'login.totp-auth-description',
icon: 'mdi:cellphone-key'
}
name: 'security.2fa.provider.totp',
description: 'login.totp-auth-description',
placeholder: 'login.totp-auth-placeholder',
icon: 'mdi:cellphone-key'
}
],
[
TwoFactorAuthProviderType.SMS, {
name: 'security.2fa.provider.sms',
description: 'login.sms-auth-description',
icon: 'mdi:message-reply-text-outline'
}
name: 'security.2fa.provider.sms',
description: 'login.sms-auth-description',
placeholder: 'login.sms-auth-placeholder',
icon: 'mdi:message-reply-text-outline'
}
],
[
TwoFactorAuthProviderType.EMAIL, {
name: 'security.2fa.provider.email',
description: 'login.email-auth-description',
icon: 'mdi:email-outline'
}
name: 'security.2fa.provider.email',
description: 'login.email-auth-description',
placeholder: 'login.email-auth-placeholder',
icon: 'mdi:email-outline'
}
],
[
TwoFactorAuthProviderType.BACKUP_CODE, {
name: 'security.2fa.provider.backup_code',
description: 'login.backup-code-auth-description',
placeholder: 'login.backup-code-auth-placeholder',
icon: 'mdi:lock-outline'
}
]
]
);

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

@ -57,7 +57,8 @@
"download": "Stáhnout",
"next-with-label": "Další: {{label}}",
"read-more": "Zobrazit více",
"hide": "Skrýt"
"hide": "Skrýt",
"done": "Hotovo"
},
"aggregation": {
"aggregation": "Agregace",
@ -2169,8 +2170,7 @@
"select-file": "Vybrat soubor",
"configuration": "Importovat konfiguraci",
"column-type": "Vybrat typ sloupců",
"creat-entities": "Vytvořím nové entity",
"done": "Hotovo"
"creat-entities": "Vytvořím nové entity"
},
"message": {
"create-entities": "{{count}} nových entit bylo úspěšně vytvořeno.",

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

@ -53,7 +53,8 @@
"share-via": "Διαμοίραση μέσω {{provider}}",
"move": "Μετακίνηση",
"select": "Επιλογή",
"continue": "Συνέχεια"
"continue": "Συνέχεια",
"done": "Ολοκληρώθηκε"
},
"aggregation": {
"aggregation": "Συνάθροιση",
@ -1479,8 +1480,7 @@
"select-file": "Επιλογή αρχείου",
"configuration": "Εισαγωγή ρύθμισης",
"column-type": "Επιλογή τύπου στήλης",
"creat-entities": "Δημιουργία νέων οντοτήτων",
"done": "Ολοκληρώθηκε"
"creat-entities": "Δημιουργία νέων οντοτήτων"
},
"message": {
"create-entities": "{{count}} νέες οντότητες δημιουργήθηκαν με επιτυχία.",

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

@ -57,7 +57,9 @@
"download": "Download",
"next-with-label": "Next: {{label}}",
"read-more": "Read more",
"hide": "Hide"
"hide": "Hide",
"done": "Done",
"print": "Print"
},
"aggregation": {
"aggregation": "Aggregation",
@ -323,6 +325,9 @@
"number-of-checking-attempts": "Number of checking attempts",
"number-of-checking-attempts-pattern": "Number of checking attempts must be a positive integer.",
"number-of-checking-attempts-required": "Number of checking attempts is required.",
"number-of-codes": "Number of codes",
"number-of-codes-pattern": "Number of codes must be a positive integer.",
"number-of-codes-required": "Number of codes is required.",
"provider": "Provider",
"retry-verification-code-period": "Retry verification code period (sec)",
"retry-verification-code-period-pattern": "Minimal period time is 5 sec",
@ -2404,8 +2409,7 @@
"select-file": "Select a file",
"configuration": "Import configuration",
"column-type": "Select columns type",
"creat-entities": "Creating new entities",
"done": "Done"
"creat-entities": "Creating new entities"
},
"message": {
"create-entities": "{{count}} new entities were successfully created.",
@ -2486,8 +2490,13 @@
"resend-code-wait": "Resend code wait { time, plural, 1 {1 second} other {# seconds} }",
"try-another-way": "Try another way",
"totp-auth-description": "Please enter the security code from your authenticator app.",
"totp-auth-placeholder": "Code",
"sms-auth-description": "A security code has been sent to your phone at {{contact}}.",
"email-auth-description": "A security code has been sent to your email address at {{contact}}."
"sms-auth-placeholder": "SMS code",
"email-auth-description": "A security code has been sent to your email address at {{contact}}.",
"email-auth-placeholder": "Email code",
"backup-code-auth-description": "Please enter one of your backup codes.",
"backup-code-auth-placeholder": "Backup code"
},
"markdown": {
"copy-code": "Click to copy",
@ -2583,17 +2592,20 @@
"disable-2fa-provider-title": "Are you sure you want to disable {{name}}?",
"main-2fa-method": "Use as main two-factor authentication method",
"dialog": {
"activate": "Activate",
"activation-step-description-email": "The next time you login in, you will be prompted to enter the security code that will be sent to your email address.",
"activation-step-description-sms": "The next time you login in, you will be prompted to enter the security code that will be sent to the phone number.",
"activation-step-description-totp": "The next time you login in, you will need to provide a two-factor authentication code.",
"activation-step-label": "Activation",
"backup-code-description": "Print out the codes so you have them handy when you need to use them to log in to your account. You can use each backup code once.",
"backup-code-warn": "Once you leave this page, these codes cannot be shown again. Store them safely using the options below.",
"download-txt": "Download (txt)",
"email-step-description": "Enter an email to use as your authenticator.",
"email-step-label": "Email",
"enable-email-title": "Enable email authenticator",
"enable-sms-title": "Enable SMS authenticator",
"enable-totp-title": "Enable authenticator app",
"enter-verification-code": "Enter the 6-digit code here",
"get-backup-code-title": "Get backup code",
"next": "Next",
"scan-qr-code": "Scan this QR code with your verification app",
"send-code": "Send code",
@ -2614,7 +2626,9 @@
"sms": "SMS",
"sms-description": "Use your phone to authenticate. We'll send you a security code via SMS message when you log in.",
"totp": "Authenticator app",
"totp-description": "Use apps like Google Authenticator, Authy, or Duo on your phone to authenticate. It will generate a security code for logging in."
"totp-description": "Use apps like Google Authenticator, Authy, or Duo on your phone to authenticate. It will generate a security code for logging in.",
"backup_code": "Backup code",
"backup-code-description": "These printable one-time passcodes allow you to sign in when away from your phone, like when you’re traveling."
}
}
},

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

@ -57,7 +57,8 @@
"download": "Descargar",
"next-with-label": "Siguiente: {{label}}",
"read-more": "Leer más",
"hide": "Ocultar"
"hide": "Ocultar",
"done": "Hecho"
},
"aggregation": {
"aggregation": "Agrupación",
@ -1892,8 +1893,7 @@
"select-file": "Seleccione un archivo",
"configuration": "Importar configuración",
"column-type": "Seleccionar tipo de columnas",
"creat-entities": "Creando nuevas entidades",
"done": "Hecho"
"creat-entities": "Creando nuevas entidades"
},
"message": {
"create-entities": "Se crearon{{count}} nuevas entidades correctamente.",

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

@ -53,7 +53,8 @@
"export": "ექსპორტი",
"share-via": "გაზიარება როგორც {{provider}}",
"continue": "გაგრძელება",
"discard-changes": "ცვლილებების გაუქმება"
"discard-changes": "ცვლილებების გაუქმება",
"done": "შესრულებულია"
},
"aggregation": {
"aggregation": "აგრეგაცია",
@ -1191,8 +1192,7 @@
"select-file": "აირჩიე ფაილი",
"configuration": "დააიმპორტე კონფიგურაცია",
"column-type": "აირჩიე სვეტის ტიპი",
"creat-entities": "იქმნება ახალი ობიექტები",
"done": "შესრულებულია"
"creat-entities": "იქმნება ახალი ობიექტები"
},
"message": {
"create-entities": "{{count}} ახალი ობიექტები წარმატებით შეიქმნა.",

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

@ -57,7 +57,8 @@
"download": "다운로드",
"next-with-label": "다음: {{label}}",
"read-more": "더보기",
"hide": "숨기기"
"hide": "숨기기",
"done": "완료"
},
"aggregation": {
"aggregation": "집합",
@ -1724,8 +1725,7 @@
"select-file": "파일 선택",
"configuration": "설정 불러오기",
"column-type": "컬럼 유형 선택",
"creat-entities": "새로운 개체 생성",
"done": "완료"
"creat-entities": "새로운 개체 생성"
},
"message": {
"create-entities": "{{count}}개의 새로운 개체사 성공적으로 생성되었습니다.",

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

@ -49,7 +49,8 @@
"import": "Importēt",
"export": "Eksportēt",
"share-via": "Dalīties caur {{provider}}",
"continue": "Turpināt"
"continue": "Turpināt",
"done": "Darīts"
},
"aggregation": {
"aggregation": "Sakopojums",
@ -1125,8 +1126,7 @@
"select-file": "Atlasīt failu",
"configuration": "Importēt konfigurāciju",
"column-type": "Atlasīt kolonas tipu",
"creat-entities": "Radīt jaunas vienības",
"done": "Darīts"
"creat-entities": "Radīt jaunas vienības"
},
"message": {
"create-entities": "{{count}} jaunas vienības sekmīgi radītas.",

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

@ -54,7 +54,8 @@
"share-via": "Compartilhar via {{provider}}",
"continue": "Continuar",
"discard-changes": "Descartar alterações",
"download": "Download"
"download": "Download",
"done": "Concluído"
},
"aggregation": {
"aggregation": "Agregação",
@ -1387,8 +1388,7 @@
"select-file": "Selecionar um arquivo",
"configuration": "Importar configuração",
"column-type": "Selecionar tipos de colunas",
"creat-entities": "Criar novas entidades",
"done": "Concluído"
"creat-entities": "Criar novas entidades"
},
"message": {
"create-entities": "{{count}} novas entidades foram criadas corretamente.",

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

@ -50,7 +50,8 @@
"export": "Export",
"share-via": "Distribuie prin {{provider}}",
"continue": "Continuă",
"discard-changes": "Anulează Schimbări"
"discard-changes": "Anulează Schimbări",
"done": "Terminat"
},
"aggregation": {
"aggregation": "Agregare",
@ -1177,8 +1178,7 @@
"select-file": "Selectează un fişier",
"configuration": "Importă configurație",
"column-type": "Selectează tipul de coloane",
"creat-entities": "Creează entităţi noi",
"done": "Terminat"
"creat-entities": "Creează entităţi noi"
},
"message": {
"create-entities": "{{count}} entităţi noi au fost create cu succes",

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

@ -50,7 +50,8 @@
"export": "Экспортировать",
"share-via": "Поделиться в {{provider}}",
"continue": "Продолжить",
"discard-changes": "Отменить изменения"
"discard-changes": "Отменить изменения",
"done": "Завершено"
},
"aggregation": {
"aggregation": "Агрегация",
@ -1206,8 +1207,7 @@
"select-file": "Выберите файл",
"configuration": "Конфигурация импорта",
"column-type": "Выберите тип колонок",
"creat-entities": "Создание новых объектов",
"done": "Завершено"
"creat-entities": "Создание новых объектов"
},
"message": {
"create-entities": "{{count}} новый(х) объект(ов) было успешно создано.",

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

@ -57,7 +57,8 @@
"download": "Prenesi",
"next-with-label": "Naslednji: {{label}}",
"read-more": "Preberi več",
"hide": "Skrij"
"hide": "Skrij",
"done": "Končano"
},
"aggregation": {
"aggregation": "Združevanje",
@ -1724,8 +1725,7 @@
"select-file": "Izberi datoteko",
"configuration": "Uvozi konfiguracijo",
"column-type": "Izberi vrsto stolpca",
"creat-entities": "Ustvarjanje novih entitet",
"done": "Končano"
"creat-entities": "Ustvarjanje novih entitet"
},
"message": {
"create-entities": "{{count}} novih entitet je bilo uspešno ustvarjenih.",

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

@ -57,7 +57,8 @@
"download": "İndir",
"next-with-label": "Sonraki: {{label}}",
"read-more": "Devamını Oku",
"hide": "Gizle"
"hide": "Gizle",
"done": "Tamamlandı"
},
"aggregation": {
"aggregation": "Aggregation",
@ -2184,8 +2185,7 @@
"select-file": "Bir dosya seçin",
"configuration": "Yapılandırmayı içe aktar",
"column-type": "Sütun türünü seçin",
"creat-entities": "Yeni varlıklar oluşturma",
"done": "Tamamlandı"
"creat-entities": "Yeni varlıklar oluşturma"
},
"message": {
"create-entities": "{{count}} yeni öğe başarıyla oluşturuldu.",

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

@ -52,7 +52,8 @@
"continue": "Продовжити",
"discard-changes": "Скасувати зміни",
"move": "Перемістити",
"select": "Вибрати"
"select": "Вибрати",
"done": "Завершено"
},
"aggregation": {
"aggregation": "Агрегація",
@ -1458,8 +1459,7 @@
"select-file": "Виберіть файл",
"configuration": "Конфігурація імпорту",
"column-type": "Виберіть тип колонок",
"creat-entities": "Створення нових сутностей",
"done": "Завершено"
"creat-entities": "Створення нових сутностей"
},
"message": {
"create-entities": "{{count}} нову(их) сутність(ей) успішно створено.",

Loading…
Cancel
Save