{{ translate.instant('import.message.create-entities', {count: this.statistical.created}) }}
diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts
index 3b3100f47a..e5426d0ce0 100644
--- a/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts
+++ b/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'
diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts
index d502aacc08..6e81442923 100644
--- a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts
+++ b/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, 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();
diff --git a/ui-ngx/src/app/modules/home/models/services.map.ts b/ui-ngx/src/app/modules/home/models/services.map.ts
index 1f3a2bf5ae..9795dcdeda 100644
--- a/ui-ngx/src/app/modules/home/models/services.map.ts
+++ b/ui-ngx/src/app/modules/home/models/services.map.ts
@@ -36,6 +36,7 @@ import { BroadcastService } from '@core/services/broadcast.service';
import { ImportExportService } from '@home/components/import-export/import-export.service';
import { DeviceProfileService } from '@core/http/device-profile.service';
import { OtaPackageService } from '@core/http/ota-package.service';
+import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
export const ServicesMap = new Map>(
[
@@ -59,6 +60,7 @@ export const ServicesMap = new Map>(
['router', Router],
['importExport', ImportExportService],
['deviceProfileService', DeviceProfileService],
- ['otaPackageService', OtaPackageService]
+ ['otaPackageService', OtaPackageService],
+ ['twoFactorAuthenticationService', TwoFactorAuthenticationService]
]
);
diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
index e5252f915f..e74bb0ae4d 100644
--- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
+++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
@@ -33,6 +33,7 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai
import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models';
import { BreadCrumbConfig } from '@shared/components/breadcrumb';
import { QueuesTableConfigResolver } from '@home/pages/admin/queue/queues-table-config.resolver';
+import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component';
@Injectable()
export class OAuth2LoginProcessingUrlResolver implements Resolve {
@@ -222,6 +223,20 @@ const routes: Routes = [
}
}
]
+ },
+ {
+ path: '2fa',
+ component: TwoFactorAuthSettingsComponent,
+ canDeactivate: [ConfirmOnExitGuard],
+ data: {
+ auth: [Authority.SYS_ADMIN],
+ title: 'admin.2fa.2fa',
+ breadcrumb: {
+ label: 'admin.2fa.2fa',
+ icon: 'mdi:two-factor-authentication',
+ isMdiIcon: true
+ }
+ }
}
]
}
diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
index 6f5e91bd61..a6e9393611 100644
--- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
+++ b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
@@ -29,6 +29,7 @@ import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dial
import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component';
import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component';
import { QueueComponent} from '@home/pages/admin/queue/queue.component';
+import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component';
@NgModule({
declarations:
@@ -41,7 +42,8 @@ import { QueueComponent} from '@home/pages/admin/queue/queue.component';
OAuth2SettingsComponent,
HomeSettingsComponent,
ResourcesLibraryComponent,
- QueueComponent
+ QueueComponent,
+ TwoFactorAuthSettingsComponent
],
imports: [
CommonModule,
diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html
new file mode 100644
index 0000000000..1c6124bdc7
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html
@@ -0,0 +1,202 @@
+
+
+
+
+
+ admin.2fa.2fa
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss
new file mode 100644
index 0000000000..254cc51c22
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss
@@ -0,0 +1,90 @@
+/**
+ * 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 "../../../../../scss/constants";
+
+:host {
+ mat-card.settings-card {
+ @media #{$mat-md} {
+ width: 90%;
+ }
+
+ .fields-group {
+ margin: 8px 0;
+ border: 1px groove rgba(0, 0, 0, .25);
+ border-radius: 4px;
+ position: relative;
+ padding-bottom: 8px;
+
+ legend {
+ color: rgba(0, 0, 0, .7);
+ width: fit-content;
+ margin: 0 8px;
+ }
+
+ .input-row {
+ padding: 8px 8px 0;
+ }
+
+ &:last-of-type {
+ margin-bottom: 24px;
+ }
+ }
+
+ .mat-expansion-panel {
+ box-shadow: none;
+ margin: 1px 0 0;
+
+ &.provider {
+ overflow: inherit;
+
+ .mat-expansion-panel-header {
+ padding: 0 24px 0 8px;
+
+ &.mat-expanded {
+ height: 48px;
+ }
+
+ .mat-slide-toggle {
+ margin-right: 8px;
+ }
+ }
+
+ .mat-expansion-panel-header-title {
+ height: 40px;
+ }
+ }
+ }
+ }
+}
+
+:host ::ng-deep {
+ .mat-expansion-panel {
+ .mat-expansion-panel-content {
+ font-size: 16px;
+ }
+
+ &.provider {
+ .mat-expansion-panel-header > .mat-content {
+ overflow: inherit;
+ }
+
+ .mat-expansion-panel-body {
+ padding: 0 16px 8px 8px;
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts
new file mode 100644
index 0000000000..f4c7c2f1c9
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts
@@ -0,0 +1,246 @@
+///
+/// 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, 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';
+import { AppState } from '@core/core.state';
+import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
+import {
+ TwoFactorAuthProviderConfigForm,
+ twoFactorAuthProvidersData,
+ TwoFactorAuthProviderType,
+ TwoFactorAuthSettings,
+ TwoFactorAuthSettingsForm
+} from '@shared/models/two-factor-auth.models';
+import { isNotEmptyStr } from '@core/utils';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { MatExpansionPanel } from '@angular/material/expansion';
+
+@Component({
+ selector: 'tb-2fa-settings',
+ templateUrl: './two-factor-auth-settings.component.html',
+ styleUrls: [ './settings-card.scss', './two-factor-auth-settings.component.scss']
+})
+export class TwoFactorAuthSettingsComponent extends PageComponent implements OnInit, HasConfirmForm, OnDestroy {
+
+ private readonly destroy$ = new Subject();
+ private readonly posIntValidation = [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)];
+
+ twoFaFormGroup: FormGroup;
+ twoFactorAuthProviderType = TwoFactorAuthProviderType;
+ twoFactorAuthProvidersData = twoFactorAuthProvidersData;
+
+ @ViewChildren(MatExpansionPanel) expansionPanel: QueryList;
+
+ constructor(protected store: Store,
+ private twoFaService: TwoFactorAuthenticationService,
+ private fb: FormBuilder) {
+ super(store);
+ }
+
+ ngOnInit() {
+ this.build2faSettingsForm();
+ this.twoFaService.getTwoFaSettings().subscribe((setting) => {
+ this.setAuthConfigFormValue(setting);
+ });
+ }
+
+ ngOnDestroy() {
+ super.ngOnDestroy();
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ confirmForm(): FormGroup {
+ return this.twoFaFormGroup;
+ }
+
+ save() {
+ if (this.twoFaFormGroup.valid) {
+ const setting = this.twoFaFormGroup.getRawValue() as TwoFactorAuthSettingsForm;
+ this.joinRateLimit(setting, 'verificationCodeCheckRateLimit');
+ const providers = setting.providers.filter(provider => provider.enable);
+ providers.forEach(provider => delete provider.enable);
+ const config = Object.assign(setting, {providers});
+ this.twoFaService.saveTwoFaSettings(config).subscribe(
+ (settings) => {
+ this.setAuthConfigFormValue(settings);
+ this.twoFaFormGroup.markAsUntouched();
+ this.twoFaFormGroup.markAsPristine();
+ }
+ );
+ } else {
+ Object.keys(this.twoFaFormGroup.controls).forEach(field => {
+ const control = this.twoFaFormGroup.get(field);
+ control.markAsTouched({onlySelf: true});
+ });
+ }
+ }
+
+ toggleExtensionPanel($event: Event, index: number, currentState: boolean) {
+ if ($event) {
+ $event.stopPropagation();
+ }
+ if (currentState) {
+ this.getByIndexPanel(index).close();
+ } else {
+ this.getByIndexPanel(index).open();
+ }
+ }
+
+ trackByElement(i: number, item: any) {
+ return item;
+ }
+
+ get providersForm(): FormArray {
+ return this.twoFaFormGroup.get('providers') as FormArray;
+ }
+
+ private build2faSettingsForm(): void {
+ this.twoFaFormGroup = this.fb.group({
+ maxVerificationFailuresBeforeUserLockout: [30, [
+ Validators.pattern(/^\d*$/),
+ Validators.min(0),
+ Validators.max(65535)
+ ]],
+ totalAllowedTimeForVerification: [3600, [
+ Validators.required,
+ Validators.min(60),
+ Validators.pattern(/^\d*$/)
+ ]],
+ verificationCodeCheckRateLimitEnable: [false],
+ verificationCodeCheckRateLimitNumber: [{value: 3, disabled: true}, this.posIntValidation],
+ verificationCodeCheckRateLimitTime: [{value: 900, disabled: true}, this.posIntValidation],
+ minVerificationCodeSendPeriod: ['30', [Validators.required, Validators.min(5), Validators.pattern(/^\d*$/)]],
+ providers: this.fb.array([])
+ });
+ Object.values(TwoFactorAuthProviderType).forEach(provider => {
+ this.buildProvidersSettingsForm(provider);
+ });
+ this.twoFaFormGroup.get('verificationCodeCheckRateLimitEnable').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(value => {
+ if (value) {
+ this.twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').enable({emitEvent: false});
+ this.twoFaFormGroup.get('verificationCodeCheckRateLimitTime').enable({emitEvent: false});
+ } else {
+ this.twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').disable({emitEvent: false});
+ this.twoFaFormGroup.get('verificationCodeCheckRateLimitTime').disable({emitEvent: false});
+ }
+ });
+ this.providersForm.valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe((value: TwoFactorAuthProviderConfigForm[]) => {
+ const activeProvider = value.filter(provider => provider.enable);
+ const indexBackupCode = Object.values(TwoFactorAuthProviderType).indexOf(TwoFactorAuthProviderType.BACKUP_CODE);
+ if (!activeProvider.length ||
+ activeProvider.length === 1 && activeProvider[0].providerType === TwoFactorAuthProviderType.BACKUP_CODE) {
+ this.providersForm.at(indexBackupCode).get('enable').setValue(false, {emitEvent: false});
+ this.providersForm.at(indexBackupCode).disable( {emitEvent: false});
+ this.providersForm.at(indexBackupCode).get('providerType').enable({emitEvent: false});
+ } else {
+ this.providersForm.at(indexBackupCode).get('enable').enable( {emitEvent: false});
+ }
+ });
+ }
+
+ private setAuthConfigFormValue(settings: TwoFactorAuthSettings) {
+ const [checkRateLimitNumber, checkRateLimitTime] = this.splitRateLimit(settings?.verificationCodeCheckRateLimit);
+ const allowProvidersConfig = settings?.providers.map(provider => provider.providerType) || [];
+ const processFormValue: TwoFactorAuthSettingsForm = Object.assign({}, settings, {
+ verificationCodeCheckRateLimitEnable: checkRateLimitNumber > 0,
+ verificationCodeCheckRateLimitNumber: checkRateLimitNumber || 3,
+ verificationCodeCheckRateLimitTime: checkRateLimitTime || 900,
+ providers: []
+ });
+ if (checkRateLimitNumber > 0) {
+ this.getByIndexPanel(this.providersForm.length).open();
+ }
+ Object.values(TwoFactorAuthProviderType).forEach((provider, index) => {
+ const findIndex = allowProvidersConfig.indexOf(provider);
+ if (findIndex > -1) {
+ processFormValue.providers.push(Object.assign(settings.providers[findIndex], {enable: true}));
+ this.getByIndexPanel(index).open();
+ } else {
+ processFormValue.providers.push({enable: false});
+ }
+ });
+ this.twoFaFormGroup.patchValue(processFormValue);
+ }
+
+ private buildProvidersSettingsForm(provider: TwoFactorAuthProviderType) {
+ const formControlConfig: {[key: string]: any} = {
+ providerType: [provider],
+ enable: [false]
+ };
+ switch (provider) {
+ case TwoFactorAuthProviderType.TOTP:
+ formControlConfig.issuerName = [{value: 'ThingsBoard', disabled: true}, [Validators.required, Validators.pattern(/^\S+$/)]];
+ break;
+ case TwoFactorAuthProviderType.SMS:
+ formControlConfig.smsVerificationMessageTemplate = [{value: 'Verification code: ${code}', disabled: true}, [
+ Validators.required,
+ Validators.pattern(/\${code}/)
+ ]];
+ formControlConfig.verificationCodeLifetime = [{value: 120, disabled: true}, this.posIntValidation];
+ break;
+ case TwoFactorAuthProviderType.EMAIL:
+ 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);
+ newProviders.get('enable').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(value => {
+ if (value) {
+ newProviders.enable({emitEvent: false});
+ } else {
+ newProviders.disable({emitEvent: false});
+ newProviders.get('enable').enable({emitEvent: false});
+ newProviders.get('providerType').enable({emitEvent: false});
+ }
+ });
+ this.providersForm.push(newProviders, {emitEvent: false});
+ }
+
+ private getByIndexPanel(index: number) {
+ return this.expansionPanel.find((_, i) => i === index);
+ }
+
+ private splitRateLimit(setting: string): [number, number] {
+ if (isNotEmptyStr(setting)) {
+ const [attemptNumber, time] = setting.split(':');
+ return [parseInt(attemptNumber, 10), parseInt(time, 10)];
+ }
+ return [0, 0];
+ }
+
+ private joinRateLimit(processFormValue: TwoFactorAuthSettingsForm, property: string) {
+ if (processFormValue[`${property}Enable`]) {
+ processFormValue[property] = [processFormValue[`${property}Number`], processFormValue[`${property}Time`]].join(':');
+ }
+ delete processFormValue[`${property}Enable`];
+ delete processFormValue[`${property}Number`];
+ delete processFormValue[`${property}Time`];
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts
index 576ab1c9b2..cf3aec6191 100644
--- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts
+++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts
@@ -19,6 +19,7 @@ import { NgModule } from '@angular/core';
import { AdminModule } from './admin/admin.module';
import { HomeLinksModule } from './home-links/home-links.module';
import { ProfileModule } from './profile/profile.module';
+import { SecurityModule } from '@home/pages/security/security.module';
import { TenantModule } from '@modules/home/pages/tenant/tenant.module';
import { CustomerModule } from '@modules/home/pages/customer/customer.module';
import { AuditLogModule } from '@modules/home/pages/audit-log/audit-log.module';
@@ -42,6 +43,7 @@ import { OtaUpdateModule } from '@home/pages/ota-update/ota-update.module';
AdminModule,
HomeLinksModule,
ProfileModule,
+ SecurityModule,
TenantProfileModule,
TenantModule,
DeviceProfileModule,
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.scss
new file mode 100644
index 0000000000..ec6f008a80
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.scss
@@ -0,0 +1,15 @@
+/**
+ * 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.
+ */
diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html
index b0bcabf62b..7300f7ea7c 100644
--- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html
+++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html
@@ -18,12 +18,13 @@
-
+
profile.profile
{{ profile ? profile.get('email').value : '' }}
-
+
{{ user?.additionalInfo?.lastLoginTs | date:'yyyy-MM-dd HH:mm:ss' }}
@@ -77,24 +78,25 @@
{{ 'dashboard.home-dashboard-hide-toolbar' | translate }}
-
-
- {{'profile.change-password' | translate}}
-
-
-
-
-
- {{ 'profile.copy-jwt-token' | translate }}
-
-
{{ expirationJwtData }}
+
+
+
+ {{'profile.change-password' | translate}}
+
+
+
+
+
+ {{ 'profile.copy-jwt-token' | translate }}
+
+
{{ expirationJwtData }}
+
-
-
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss b/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss
index 08916ca584..737c7c0999 100644
--- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss
+++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss
@@ -39,8 +39,10 @@
font-weight: 400;
}
.profile-btn-subtext {
- opacity: 0.7;
- padding: 10px;
+ font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif;
+ letter-spacing: 0.25px;
+ opacity: 0.6;
+ padding: 8px 0;
}
.tb-home-dashboard {
tb-dashboard-autocomplete {
diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts
index 610006a3fd..76187b58c2 100644
--- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts
+++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts
@@ -83,7 +83,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir
this.userLoaded(this.route.snapshot.data.user);
}
- buildProfileForm() {
+ private buildProfileForm() {
this.profile = this.fb.group({
email: ['', [Validators.required, Validators.email]],
firstName: [''],
@@ -128,7 +128,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir
});
}
- userLoaded(user: User) {
+ private userLoaded(user: User) {
this.user = user;
this.profile.reset(user);
let lang;
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss
new file mode 100644
index 0000000000..116db0fb85
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss
@@ -0,0 +1,120 @@
+/**
+ * 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 "../../../../../../scss/constants";
+
+:host {
+ .mat-toolbar > h2 {
+ font-weight: 400;
+ letter-spacing: 0.25px;
+ }
+
+ form {
+ display: block;
+ }
+
+ .mat-body-1 {
+ margin-bottom: 0;
+ letter-spacing: 0.25px;
+
+ &: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 {
+ max-width: 290px;
+ }
+
+ .code-container {
+ max-width: 170px;
+
+ &.full-width-xs {
+ @media #{$mat-xs} {
+ max-width: 100%;
+ width: 100%;
+ }
+ }
+ }
+
+ .result-title {
+ font: 500 18px / 24px Roboto, "Helvetica Neue", sans-serif;
+ letter-spacing: 0.1px;
+ margin: 8px 0;
+ text-align: center;
+ }
+
+ .result-description {
+ text-align: center;
+ margin: 0 0 16px;
+ letter-spacing: 0.25px;
+ max-width: 500px;
+ }
+
+ .step-description {
+ max-width: 450px;
+
+ &.input {
+ margin: 12px 0 0;
+ }
+ }
+
+ .qr-code-description {
+ text-align: center;
+ max-width: 180px;
+ letter-spacing: 0.25px;
+ color: rgba(0, 0, 0, 0.87);
+ margin-bottom: 0;
+ }
+
+ .backup-code {
+ max-width: 500px;
+
+ .container {
+ max-width: 500px;
+ margin: 40px 0 8px;
+
+ .code {
+ letter-spacing: 0.25px;
+ padding: 0 24px;
+ margin-bottom: 16px;
+ font-family: Roboto Mono, "Helvetica Neue", monospace;
+
+ &.even {
+ text-align: right;
+ }
+ }
+ }
+
+ .action-buttons {
+ margin-bottom: 40px;
+ }
+ }
+
+ & ::ng-deep {
+ .mat-horizontal-stepper-header {
+ pointer-events: none !important;
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts
new file mode 100644
index 0000000000..c5f319872e
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts
@@ -0,0 +1,33 @@
+///
+/// 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 { Type } from '@angular/core';
+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.TOTP, TotpAuthDialogComponent],
+ [TwoFactorAuthProviderType.SMS, SMSAuthDialogComponent],
+ [TwoFactorAuthProviderType.EMAIL, EmailAuthDialogComponent],
+ [TwoFactorAuthProviderType.BACKUP_CODE, BackupCodeAuthDialogComponent]
+ ]
+);
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html
new file mode 100644
index 0000000000..ab3ba1a797
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html
@@ -0,0 +1,51 @@
+
+
+ security.2fa.dialog.get-backup-code-title
+
+
+ close
+
+
+
+
+
+
+
security.2fa.dialog.backup-code-description
+
+
+
+ {{ 'security.2fa.dialog.download-txt' | translate }}
+
+
+ {{ 'action.print' | translate }}
+
+
+
security.2fa.dialog.backup-code-warn
+
+
+ {{ 'action.done' | translate }}
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.ts
new file mode 100644
index 0000000000..5d3a26b13d
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.ts
@@ -0,0 +1,88 @@
+///
+/// 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';
+import { deepClone } from '@core/utils';
+
+import printTemplate from '!raw-loader!./backup-code-print-template.raw';
+
+@Component({
+ selector: 'tb-backup-code-auth-dialog',
+ templateUrl: './backup-code-auth-dialog.component.html',
+ styleUrls: ['./authentication-dialog.component.scss']
+})
+export class BackupCodeAuthDialogComponent extends DialogComponent {
+
+ private config: AccountTwoFaSettings;
+ backupCode: BackupCodeTwoFactorAuthAccountConfig;
+
+ constructor(protected store: Store,
+ protected router: Router,
+ private twoFaService: TwoFactorAuthenticationService,
+ private importExportService: ImportExportService,
+ public dialogRef: MatDialogRef,
+ 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');
+ }
+
+ printCode() {
+ const codeTemplate = deepClone(this.backupCode.codes)
+ .map(code => `${code}
`).join('');
+ const printPage = printTemplate.replace('${codesBlock}', codeTemplate);
+ const newWindow = window.open('', 'Print backup code');
+
+ newWindow.document.open();
+ newWindow.document.write(printPage);
+
+ setTimeout(() => {
+ newWindow.print();
+
+ newWindow.document.close();
+
+ setTimeout(() => {
+ newWindow.close();
+ }, 10);
+ }, 0);
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-print-template.raw b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-print-template.raw
new file mode 100644
index 0000000000..1ab7efc79f
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-print-template.raw
@@ -0,0 +1,50 @@
+
+
+
+
+ Backup code
+
+
+
+
+
+
+
+
+ Backup codes
+
+
+ ${codesBlock}
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html
new file mode 100644
index 0000000000..45663845be
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html
@@ -0,0 +1,111 @@
+
+
+ security.2fa.dialog.enable-email-title
+
+
+ close
+
+
+
+
+
+
+
+
+ done
+
+
+ {{ 'security.2fa.dialog.email-step-label' | translate }}
+
+
+
+ {{ 'security.2fa.dialog.verification-step-label' | translate }}
+
+
+
+ {{ 'security.2fa.dialog.activation-step-label' | translate }}
+
+
security.2fa.dialog.success
+
security.2fa.dialog.activation-step-description-email
+
+
+ {{ 'action.done' | translate }}
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts
new file mode 100644
index 0000000000..385ea0e818
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts
@@ -0,0 +1,113 @@
+///
+/// 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, Inject, ViewChild } 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 { 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 {
+ AccountTwoFaSettings,
+ TwoFactorAuthAccountConfig,
+ TwoFactorAuthProviderType
+} from '@shared/models/two-factor-auth.models';
+import { MatStepper } from '@angular/material/stepper';
+
+export interface EmailAuthDialogData {
+ email: string;
+}
+
+@Component({
+ selector: 'tb-email-auth-dialog',
+ templateUrl: './email-auth-dialog.component.html',
+ styleUrls: ['./authentication-dialog.component.scss']
+})
+export class EmailAuthDialogComponent extends DialogComponent {
+
+ private authAccountConfig: TwoFactorAuthAccountConfig;
+ private config: AccountTwoFaSettings;
+
+ emailConfigForm: FormGroup;
+ emailVerificationForm: FormGroup;
+
+ @ViewChild('stepper', {static: false}) stepper: MatStepper;
+
+ constructor(protected store: Store,
+ protected router: Router,
+ private twoFaService: TwoFactorAuthenticationService,
+ @Inject(MAT_DIALOG_DATA) public data: EmailAuthDialogData,
+ public dialogRef: MatDialogRef,
+ public fb: FormBuilder) {
+ super(store, router, dialogRef);
+
+ this.emailConfigForm = this.fb.group({
+ email: [this.data.email, [Validators.required, Validators.email]]
+ });
+
+ this.emailVerificationForm = this.fb.group({
+ verificationCode: ['', [
+ Validators.required,
+ Validators.minLength(6),
+ Validators.maxLength(6),
+ Validators.pattern(/^\d*$/)
+ ]]
+ });
+ }
+
+ nextStep() {
+ switch (this.stepper.selectedIndex) {
+ case 0:
+ if (this.emailConfigForm.valid) {
+ this.authAccountConfig = {
+ providerType: TwoFactorAuthProviderType.EMAIL,
+ useByDefault: true,
+ email: this.emailConfigForm.get('email').value as string
+ };
+ this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => {
+ this.stepper.next();
+ });
+ } else {
+ this.showFormErrors(this.emailConfigForm);
+ }
+ break;
+ case 1:
+ if (this.emailVerificationForm.valid) {
+ this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig,
+ this.emailVerificationForm.get('verificationCode').value).subscribe((config) => {
+ this.config = config;
+ this.stepper.next();
+ });
+ } else {
+ this.showFormErrors(this.emailVerificationForm);
+ }
+ break;
+ }
+ }
+
+ closeDialog() {
+ return this.dialogRef.close(this.config);
+ }
+
+ private showFormErrors(form: FormGroup) {
+ Object.keys(form.controls).forEach(field => {
+ const control = form.get(field);
+ control.markAsTouched({onlySelf: true});
+ });
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html
new file mode 100644
index 0000000000..d4fb60095a
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html
@@ -0,0 +1,113 @@
+
+
+ security.2fa.dialog.enable-sms-title
+
+
+ close
+
+
+
+
+
+
+
+
+ done
+
+
+ {{ 'security.2fa.dialog.sms-step-label' | translate }}
+
+
+
+ {{ 'security.2fa.dialog.verification-step-label' | translate }}
+
+
+
+ {{ 'security.2fa.dialog.activation-step-label' | translate }}
+
+
security.2fa.dialog.success
+
security.2fa.dialog.activation-step-description-sms
+
+
+ {{ 'action.done' | translate }}
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts
new file mode 100644
index 0000000000..7ab946b86f
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts
@@ -0,0 +1,111 @@
+///
+/// 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, ViewChild } 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 { MatDialogRef } from '@angular/material/dialog';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
+import {
+ AccountTwoFaSettings,
+ TwoFactorAuthAccountConfig,
+ TwoFactorAuthProviderType
+} from '@shared/models/two-factor-auth.models';
+import { phoneNumberPattern } from '@shared/models/settings.models';
+import { MatStepper } from '@angular/material/stepper';
+
+@Component({
+ selector: 'tb-sms-auth-dialog',
+ templateUrl: './sms-auth-dialog.component.html',
+ styleUrls: ['./authentication-dialog.component.scss']
+})
+export class SMSAuthDialogComponent extends DialogComponent {
+
+ private authAccountConfig: TwoFactorAuthAccountConfig;
+ private config: AccountTwoFaSettings;
+
+ phoneNumberPattern = phoneNumberPattern;
+
+ smsConfigForm: FormGroup;
+ smsVerificationForm: FormGroup;
+
+ @ViewChild('stepper', {static: false}) stepper: MatStepper;
+
+ constructor(protected store: Store,
+ protected router: Router,
+ private twoFaService: TwoFactorAuthenticationService,
+ public dialogRef: MatDialogRef,
+ public fb: FormBuilder) {
+ super(store, router, dialogRef);
+
+ this.smsConfigForm = this.fb.group({
+ phone: ['', [Validators.required, Validators.pattern(phoneNumberPattern)]]
+ });
+
+ this.smsVerificationForm = this.fb.group({
+ verificationCode: ['', [
+ Validators.required,
+ Validators.minLength(6),
+ Validators.maxLength(6),
+ Validators.pattern(/^\d*$/)
+ ]]
+ });
+ }
+
+ nextStep() {
+ switch (this.stepper.selectedIndex) {
+ case 0:
+ if (this.smsConfigForm.valid) {
+ this.authAccountConfig = {
+ providerType: TwoFactorAuthProviderType.SMS,
+ useByDefault: true,
+ phoneNumber: this.smsConfigForm.get('phone').value as string
+ };
+ this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => {
+ this.stepper.next();
+ });
+ } else {
+ this.showFormErrors(this.smsConfigForm);
+ }
+ break;
+ case 1:
+ if (this.smsVerificationForm.valid) {
+ this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig,
+ this.smsVerificationForm.get('verificationCode').value).subscribe((config) => {
+ this.config = config;
+ this.stepper.next();
+ });
+ } else {
+ this.showFormErrors(this.smsVerificationForm);
+ }
+ break;
+ }
+ }
+
+ closeDialog() {
+ return this.dialogRef.close(this.config);
+ }
+
+ private showFormErrors(form: FormGroup) {
+ Object.keys(form.controls).forEach(field => {
+ const control = form.get(field);
+ control.markAsTouched({onlySelf: true});
+ });
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html
new file mode 100644
index 0000000000..695d39860f
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html
@@ -0,0 +1,94 @@
+
+
+ security.2fa.dialog.enable-totp-title
+
+
+ close
+
+
+
+
+
+
+
+
+ done
+
+
+ {{ 'security.2fa.dialog.totp-step-label' | translate }}
+
+
security.2fa.dialog.totp-step-description-open
+
security.2fa.dialog.totp-step-description-install
+
+
+ {{ 'security.2fa.dialog.next' | translate }}
+
+
+
+
+
+ {{ 'security.2fa.dialog.verification-step-label' | translate }}
+
+
+
+ {{ 'security.2fa.dialog.next' | translate }}
+
+
+
+
+ {{ 'security.2fa.dialog.activation-step-label' | translate }}
+
+
security.2fa.dialog.success
+
security.2fa.dialog.activation-step-description-totp
+
+
+ {{ 'action.done' | translate }}
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts
new file mode 100644
index 0000000000..8bda837df3
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts
@@ -0,0 +1,93 @@
+///
+/// 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, ElementRef, ViewChild } 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 { MatDialogRef } from '@angular/material/dialog';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
+import {
+ AccountTwoFaSettings,
+ TotpTwoFactorAuthAccountConfig,
+ TwoFactorAuthProviderType
+} from '@shared/models/two-factor-auth.models';
+import { MatStepper } from '@angular/material/stepper';
+
+@Component({
+ selector: 'tb-totp-auth-dialog',
+ templateUrl: './totp-auth-dialog.component.html',
+ styleUrls: ['./authentication-dialog.component.scss']
+})
+export class TotpAuthDialogComponent extends DialogComponent {
+
+ private authAccountConfig: TotpTwoFactorAuthAccountConfig;
+ private config: AccountTwoFaSettings;
+
+ totpConfigForm: FormGroup;
+ totpAuthURL: string;
+
+ @ViewChild('stepper', {static: false}) stepper: MatStepper;
+ @ViewChild('canvas', {static: false}) canvasRef: ElementRef;
+
+ constructor(protected store: Store,
+ protected router: Router,
+ private twoFaService: TwoFactorAuthenticationService,
+ public dialogRef: MatDialogRef,
+ public fb: FormBuilder) {
+ super(store, router, dialogRef);
+ this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.TOTP).subscribe(accountConfig => {
+ this.authAccountConfig = accountConfig as TotpTwoFactorAuthAccountConfig;
+ this.totpAuthURL = this.authAccountConfig.authUrl;
+ this.authAccountConfig.useByDefault = true;
+ import('qrcode').then((QRCode) => {
+ QRCode.toCanvas(this.canvasRef.nativeElement, this.totpAuthURL);
+ this.canvasRef.nativeElement.style.width = 'auto';
+ this.canvasRef.nativeElement.style.height = 'auto';
+ });
+ });
+ this.totpConfigForm = this.fb.group({
+ verificationCode: ['', [
+ Validators.required,
+ Validators.minLength(6),
+ Validators.maxLength(6),
+ Validators.pattern(/^\d*$/)
+ ]]
+ });
+ }
+
+ onSaveConfig() {
+ if (this.totpConfigForm.valid) {
+ this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig,
+ this.totpConfigForm.get('verificationCode').value).subscribe((config) => {
+ this.config = config;
+ this.stepper.next();
+ });
+ } else {
+ Object.keys(this.totpConfigForm.controls).forEach(field => {
+ const control = this.totpConfigForm.get(field);
+ control.markAsTouched({onlySelf: true});
+ });
+ }
+ }
+
+ closeDialog() {
+ return this.dialogRef.close(this.config);
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts b/ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts
new file mode 100644
index 0000000000..430635cd8e
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts
@@ -0,0 +1,84 @@
+///
+/// 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 { Injectable, NgModule } from '@angular/core';
+import { Resolve, RouterModule, Routes } from '@angular/router';
+
+import { SecurityComponent } from './security.component';
+import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
+import { Authority } from '@shared/models/authority.enum';
+import { User } from '@shared/models/user.model';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { UserService } from '@core/http/user.service';
+import { getCurrentAuthUser } from '@core/auth/auth.selectors';
+import { Observable } from 'rxjs';
+import { TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models';
+import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
+
+@Injectable()
+export class UserProfileResolver implements Resolve {
+
+ constructor(private store: Store,
+ private userService: UserService) {
+ }
+
+ resolve(): Observable {
+ const userId = getCurrentAuthUser(this.store).userId;
+ return this.userService.getUser(userId);
+ }
+}
+
+@Injectable()
+export class UserTwoFAProvidersResolver implements Resolve> {
+
+ constructor(private twoFactorAuthService: TwoFactorAuthenticationService) {
+ }
+
+ resolve(): Observable> {
+ return this.twoFactorAuthService.getAvailableTwoFaProviders();
+ }
+}
+
+const routes: Routes = [
+ {
+ path: 'security',
+ component: SecurityComponent,
+ canDeactivate: [ConfirmOnExitGuard],
+ data: {
+ auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN, Authority.CUSTOMER_USER],
+ title: 'security.security',
+ breadcrumb: {
+ label: 'security.security',
+ icon: 'lock'
+ }
+ },
+ resolve: {
+ user: UserProfileResolver,
+ providers: UserTwoFAProvidersResolver
+ }
+ }
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule],
+ providers: [
+ UserProfileResolver,
+ UserTwoFAProvidersResolver
+ ]
+})
+export class SecurityRoutingModule { }
diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html
new file mode 100644
index 0000000000..8e279f9c75
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+ security.security
+
+
+
+ {{ user?.additionalInfo?.lastLoginTs | date:'yyyy-MM-dd HH:mm:ss' }}
+
+
+
+
+
+
+ add_circle_outline
+ {{ 'profile.copy-jwt-token' | translate }}
+
+
{{ expirationJwtData }}
+
+
+
+
+
+ admin.2fa.2fa
+
+
+ security.2fa.2fa-description
+
+
+ security.2fa.authenticate-with
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.scss b/ui-ngx/src/app/modules/home/pages/security/security.component.scss
new file mode 100644
index 0000000000..7dad927038
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/security.component.scss
@@ -0,0 +1,93 @@
+/**
+ * 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 "../../../../../scss/constants";
+
+:host {
+ .profile-container {
+ padding: 8px;
+ }
+
+ mat-card.profile-card {
+ @media #{$mat-gt-sm} {
+ width: 70%;
+ }
+ @media #{$mat-gt-md} {
+ width: 50%;
+ }
+ @media #{$mat-gt-xl} {
+ width: 45%;
+ }
+
+ .mat-subheader {
+ line-height: 24px;
+ color: rgba(0, 0, 0, 0.54);
+ font-size: 14px;
+ font-weight: 400;
+ }
+
+ .profile-last-login-ts {
+ font-size: 16px;
+ font-weight: 400;
+ }
+
+ .profile-btn-subtext {
+ font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif;
+ letter-spacing: 0.25px;
+ opacity: 0.6;
+ padding: 8px 0;
+ }
+ }
+
+ .description {
+ color: rgba(0, 0, 0, 0.54);
+ letter-spacing: 0.25px;
+ margin-right: 8px;
+ }
+
+ .mat-divider-horizontal {
+ left: 16px;
+ right: 16px;
+ width: auto;
+ }
+
+ .provider {
+ padding: 24px 0;
+
+ .provider-title {
+ font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif;
+ letter-spacing: 0.25px;
+ margin: 0 0 8px;
+ }
+
+ .description {
+ max-width: 85%;
+ }
+
+ .checkbox-label {
+ font-size: 14px;
+ letter-spacing: 0.25px;
+ opacity: 0.87;
+ }
+
+ .mat-checkbox {
+ margin-top: 8px;
+ }
+
+ .mat-stroked-button {
+ margin-top: 8px;
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.ts b/ui-ngx/src/app/modules/home/pages/security/security.component.ts
new file mode 100644
index 0000000000..ab7d470e9e
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/security.component.ts
@@ -0,0 +1,259 @@
+///
+/// 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, OnDestroy, OnInit } from '@angular/core';
+import { User } from '@shared/models/user.model';
+import { PageComponent } from '@shared/components/page.component';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { FormBuilder, FormGroup } from '@angular/forms';
+import { TranslateService } from '@ngx-translate/core';
+import { MatDialog } from '@angular/material/dialog';
+import { DialogService } from '@core/services/dialog.service';
+import { ActivatedRoute } from '@angular/router';
+import { ActionNotificationShow } from '@core/notification/notification.actions';
+import { DatePipe } from '@angular/common';
+import { ClipboardService } from 'ngx-clipboard';
+import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
+import {
+ AccountTwoFaSettings,
+ BackupCodeTwoFactorAuthAccountConfig,
+ EmailTwoFactorAuthAccountConfig,
+ SmsTwoFactorAuthAccountConfig,
+ twoFactorAuthProvidersData,
+ TwoFactorAuthProviderType
+} 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 { Observable, of, Subject } from 'rxjs';
+import { isDefinedAndNotNull } from '@core/utils';
+
+@Component({
+ selector: 'tb-security',
+ templateUrl: './security.component.html',
+ styleUrls: ['./security.component.scss']
+})
+export class SecurityComponent extends PageComponent implements OnInit, OnDestroy {
+
+ private readonly destroy$ = new Subject();
+ private accountConfig: AccountTwoFaSettings;
+
+ twoFactorAuth: FormGroup;
+ user: User;
+ allowTwoFactorProviders: TwoFactorAuthProviderType[] = [];
+ providersData = twoFactorAuthProvidersData;
+ twoFactorAuthProviderType = TwoFactorAuthProviderType;
+ useByDefault: TwoFactorAuthProviderType = null;
+ activeSingleProvider = true;
+
+ get jwtToken(): string {
+ return `Bearer ${localStorage.getItem('jwt_token')}`;
+ }
+
+ get jwtTokenExpiration(): string {
+ return localStorage.getItem('jwt_token_expiration');
+ }
+
+ get expirationJwtData(): string {
+ const expirationData = this.datePipe.transform(this.jwtTokenExpiration, 'yyyy-MM-dd HH:mm:ss');
+ return this.translate.instant('profile.valid-till', { expirationData });
+ }
+
+ constructor(protected store: Store,
+ private route: ActivatedRoute,
+ private translate: TranslateService,
+ private twoFaService: TwoFactorAuthenticationService,
+ public dialog: MatDialog,
+ public dialogService: DialogService,
+ public fb: FormBuilder,
+ private datePipe: DatePipe,
+ private clipboardService: ClipboardService) {
+ super(store);
+ }
+
+ ngOnInit() {
+ this.buildTwoFactorForm();
+ this.user = this.route.snapshot.data.user;
+ this.twoFactorLoad(this.route.snapshot.data.providers);
+ }
+
+ ngOnDestroy() {
+ super.ngOnDestroy();
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private buildTwoFactorForm() {
+ this.twoFactorAuth = this.fb.group({
+ TOTP: [false],
+ SMS: [false],
+ EMAIL: [false],
+ BACKUP_CODE: [{value: false, disabled: true}]
+ });
+ this.twoFactorAuth.valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe((value: {TwoFactorAuthProviderType: boolean}) => {
+ 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});
+ }
+ });
+ }
+
+ private twoFactorLoad(providers: TwoFactorAuthProviderType[]) {
+ if (providers.length) {
+ this.twoFaService.getAccountTwoFaSettings().subscribe(data => this.processTwoFactorAuthConfig(data));
+ Object.values(TwoFactorAuthProviderType).forEach(type => {
+ if (providers.includes(type)) {
+ this.allowTwoFactorProviders.push(type);
+ }
+ });
+ }
+ }
+
+ private processTwoFactorAuthConfig(setting: AccountTwoFaSettings) {
+ this.accountConfig = setting;
+ const configs = this.accountConfig.configs;
+ Object.values(TwoFactorAuthProviderType).forEach(provider => {
+ if (configs[provider]) {
+ this.twoFactorAuth.get(provider).setValue(true);
+ if (configs[provider].useByDefault) {
+ this.useByDefault = provider;
+ }
+ } else {
+ this.twoFactorAuth.get(provider).setValue(false);
+ }
+ });
+ }
+
+ trackByProvider(i: number, provider: TwoFactorAuthProviderType) {
+ return provider;
+ }
+
+ copyToken() {
+ if (+this.jwtTokenExpiration < Date.now()) {
+ this.store.dispatch(new ActionNotificationShow({
+ message: this.translate.instant('profile.tokenCopiedWarnMessage'),
+ type: 'warn',
+ duration: 1500,
+ verticalPosition: 'bottom',
+ horizontalPosition: 'right'
+ }));
+ } else {
+ this.clipboardService.copyFromContent(this.jwtToken);
+ this.store.dispatch(new ActionNotificationShow({
+ message: this.translate.instant('profile.tokenCopiedSuccessMessage'),
+ type: 'success',
+ duration: 750,
+ verticalPosition: 'bottom',
+ horizontalPosition: 'right'
+ }));
+ }
+ }
+
+ confirm2FAChange(event: MouseEvent, provider: TwoFactorAuthProviderType) {
+ event.stopPropagation();
+ event.preventDefault();
+ if (this.twoFactorAuth.get(provider).disabled) {
+ return;
+ }
+ if (this.twoFactorAuth.get(provider).value) {
+ const providerName = this.translate.instant(`security.2fa.provider.${provider.toLowerCase()}`);
+ this.dialogService.confirm(
+ this.translate.instant('security.2fa.disable-2fa-provider-title', {name: providerName}),
+ this.translate.instant('security.2fa.disable-2fa-provider-text', {name: providerName}),
+ ).subscribe(res => {
+ if (res) {
+ this.twoFactorAuth.disable({emitEvent: false});
+ this.twoFaService.deleteTwoFaAccountConfig(provider)
+ .pipe(tap(() => this.twoFactorAuth.enable({emitEvent: false})))
+ .subscribe(data => this.processTwoFactorAuthConfig(data));
+ }
+ });
+ } else {
+ 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();
+ if (this.useByDefault !== provider) {
+ this.twoFactorAuth.disable({emitEvent: false});
+ this.twoFaService.updateTwoFaAccountConfig(provider, true)
+ .pipe(tap(() => this.twoFactorAuth.enable({emitEvent: false})))
+ .subscribe(data => this.processTwoFactorAuthConfig(data));
+ }
+ }
+
+ generateNewBackupCode() {
+ const codeLeft = this.accountConfig.configs[TwoFactorAuthProviderType.BACKUP_CODE].codesLeft;
+ let subscription: Observable;
+ 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));
+ }
+ });
+ }
+
+ providerDataInfo(provider: TwoFactorAuthProviderType) {
+ const info = {info: null};
+ const providerConfig = this.accountConfig.configs[provider];
+ if (isDefinedAndNotNull(providerConfig)) {
+ switch (provider) {
+ case TwoFactorAuthProviderType.EMAIL:
+ info.info = (providerConfig as EmailTwoFactorAuthAccountConfig).email;
+ break;
+ case TwoFactorAuthProviderType.SMS:
+ info.info = (providerConfig as SmsTwoFactorAuthAccountConfig).phoneNumber;
+ break;
+ case TwoFactorAuthProviderType.BACKUP_CODE:
+ info.info = (providerConfig as BackupCodeTwoFactorAuthAccountConfig).codesLeft;
+ break;
+ }
+ }
+ return info;
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/security/security.module.ts b/ui-ngx/src/app/modules/home/pages/security/security.module.ts
new file mode 100644
index 0000000000..5db12c2a68
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/security/security.module.ts
@@ -0,0 +1,43 @@
+///
+/// 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 { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { SecurityComponent } from './security.component';
+import { SharedModule } from '@shared/shared.module';
+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,
+ BackupCodeAuthDialogComponent
+ ],
+ imports: [
+ CommonModule,
+ SharedModule,
+ SecurityRoutingModule
+ ]
+})
+export class SecurityModule { }
diff --git a/ui-ngx/src/app/modules/login/login-routing.module.ts b/ui-ngx/src/app/modules/login/login-routing.module.ts
index 1d10a296c4..425f6d16c8 100644
--- a/ui-ngx/src/app/modules/login/login-routing.module.ts
+++ b/ui-ngx/src/app/modules/login/login-routing.module.ts
@@ -22,6 +22,8 @@ import { AuthGuard } from '@core/guards/auth.guard';
import { ResetPasswordRequestComponent } from '@modules/login/pages/login/reset-password-request.component';
import { ResetPasswordComponent } from '@modules/login/pages/login/reset-password.component';
import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component';
+import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component';
+import { Authority } from '@shared/models/authority.enum';
const routes: Routes = [
{
@@ -69,6 +71,16 @@ const routes: Routes = [
module: 'public'
},
canActivate: [AuthGuard]
+ },
+ {
+ path: 'login/mfa',
+ component: TwoFactorAuthLoginComponent,
+ data: {
+ title: 'login.two-factor-authentication',
+ auth: [Authority.PRE_VERIFICATION_TOKEN],
+ module: 'public'
+ },
+ canActivate: [AuthGuard]
}
];
diff --git a/ui-ngx/src/app/modules/login/login.module.ts b/ui-ngx/src/app/modules/login/login.module.ts
index 78fec568c6..6414de2bf0 100644
--- a/ui-ngx/src/app/modules/login/login.module.ts
+++ b/ui-ngx/src/app/modules/login/login.module.ts
@@ -23,13 +23,15 @@ import { SharedModule } from '@app/shared/shared.module';
import { ResetPasswordRequestComponent } from '@modules/login/pages/login/reset-password-request.component';
import { ResetPasswordComponent } from '@modules/login/pages/login/reset-password.component';
import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component';
+import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component';
@NgModule({
declarations: [
LoginComponent,
ResetPasswordRequestComponent,
ResetPasswordComponent,
- CreatePasswordComponent
+ CreatePasswordComponent,
+ TwoFactorAuthLoginComponent
],
imports: [
CommonModule,
diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html
new file mode 100644
index 0000000000..f6592e686e
--- /dev/null
+++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html
@@ -0,0 +1,95 @@
+
+
+
+
+
+ chevron_left
+
+ {{ 'login.verify-your-identity' | translate }}
+
+
+
+
login.select-way-to-verify
+
+
+
+ {{ providersData.get(provider).name | translate }}
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss
new file mode 100644
index 0000000000..248d719b7b
--- /dev/null
+++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss
@@ -0,0 +1,94 @@
+/**
+ * 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 '../../../../../scss/constants';
+
+:host {
+ display: flex;
+ flex: 1 1 0;
+
+ .tb-two-factor-auth-login-content {
+ background-color: #eee;
+
+ .tb-two-factor-auth-login-card {
+ padding: 48px 48px 48px 16px;
+
+ @media #{$mat-gt-xs} {
+ width: 450px !important;
+ }
+
+ .mat-card-title {
+ font: 400 28px / 36px Roboto, "Helvetica Neue", sans-serif;
+ }
+
+ .mat-card-content {
+ margin-top: 44px;
+ margin-left: 40px;
+ }
+
+ .mat-body {
+ letter-spacing: 0.25px;
+ line-height: 16px;
+ margin: 0;
+ }
+
+ .code-block {
+ margin-top: 16px;
+ }
+
+ .providers-container {
+ padding: 0;
+
+ .mat-body {
+ padding-bottom: 8px;
+ }
+ }
+
+ .timer {
+ font: 500 12px / 14px Roboto, "Helvetica Neue", sans-serif;
+ color: rgba(255, 255, 255, 0.8);
+ }
+
+ .action-row:nth-child(n) {
+ min-height: 36px;
+
+ .action-resend {
+ min-width: 50%;
+ }
+ }
+ }
+ }
+
+ ::ng-deep {
+ button.provider {
+ text-align: start;
+ font-weight: 400;
+
+ &:not(.mat-button-disabled) {
+ border-color: rgba(255, 255, 255, .8);
+ }
+
+ .icon {
+ height: 18px;
+ width: 18px;
+ vertical-align: sub;
+ }
+ }
+
+ .mat-form-field-invalid .mat-hint {
+ margin-top: 20px;
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts
new file mode 100644
index 0000000000..73401f2a0c
--- /dev/null
+++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts
@@ -0,0 +1,197 @@
+///
+/// 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, OnDestroy, OnInit } 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 { FormBuilder, Validators } from '@angular/forms';
+import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
+import {
+ twoFactorAuthProvidersLoginData,
+ TwoFactorAuthProviderType,
+ TwoFaProviderInfo
+} from '@shared/models/two-factor-auth.models';
+import { TranslateService } from '@ngx-translate/core';
+import { interval, Subscription } from 'rxjs';
+import { isEqual } from '@core/utils';
+
+@Component({
+ selector: 'tb-two-factor-auth-login',
+ templateUrl: './two-factor-auth-login.component.html',
+ styleUrls: ['./two-factor-auth-login.component.scss']
+})
+export class TwoFactorAuthLoginComponent extends PageComponent implements OnInit, OnDestroy {
+
+ private providersInfo: TwoFaProviderInfo[];
+ private prevProvider: TwoFactorAuthProviderType;
+ private timer: Subscription;
+ private minVerificationPeriod = 0;
+ private timerID: NodeJS.Timeout;
+
+ showResendAction = false;
+ selectedProvider: TwoFactorAuthProviderType;
+ allowProviders: TwoFactorAuthProviderType[] = [];
+
+ providersData = twoFactorAuthProvidersLoginData;
+ providerDescription = '';
+ hideResendButton = true;
+ countDownTime = 0;
+
+ maxLengthInput = 6;
+ inputMode = 'numeric';
+ pattern = '[0-9]*';
+
+ verificationForm = this.fb.group({
+ verificationCode: ['', [
+ Validators.required,
+ Validators.minLength(6),
+ Validators.maxLength(6),
+ Validators.pattern(/^\d*$/)
+ ]]
+ });
+
+ constructor(protected store: Store,
+ private twoFactorAuthService: TwoFactorAuthenticationService,
+ private authService: AuthService,
+ private translate: TranslateService,
+ private fb: FormBuilder) {
+ super(store);
+ }
+
+ ngOnInit() {
+ this.providersInfo = this.authService.twoFactorAuthProviders;
+ Object.values(TwoFactorAuthProviderType).forEach(provider => {
+ const providerConfig = this.providersInfo.find(config => config.type === provider);
+ if (providerConfig) {
+ if (providerConfig.default) {
+ this.selectedProvider = providerConfig.type;
+ this.providerDescription = this.translate.instant(this.providersData.get(providerConfig.type).description, {
+ contact: providerConfig.contact
+ });
+ this.minVerificationPeriod = providerConfig?.minVerificationCodeSendPeriod || 30;
+ }
+ this.allowProviders.push(providerConfig.type);
+ }
+ });
+ if (this.selectedProvider !== TwoFactorAuthProviderType.TOTP) {
+ this.sendCode();
+ this.showResendAction = true;
+ }
+ this.timer = interval(1000).subscribe(() => this.updatedTime());
+ }
+
+ ngOnDestroy() {
+ super.ngOnDestroy();
+ this.timer.unsubscribe();
+ clearTimeout(this.timerID);
+ }
+
+ sendVerificationCode() {
+ if (this.verificationForm.valid && this.selectedProvider) {
+ this.authService.checkTwoFaVerificationCode(this.selectedProvider, this.verificationForm.get('verificationCode').value).subscribe(
+ () => {},
+ (error) => {
+ if (error.status === 400) {
+ this.verificationForm.get('verificationCode').setErrors({incorrectCode: true});
+ } else if (error.status === 429) {
+ this.verificationForm.get('verificationCode').setErrors({tooManyRequest: true});
+ this.timerID = setTimeout(() => {
+ let errors = this.verificationForm.get('verificationCode').errors;
+ delete errors.tooManyRequest;
+ if (isEqual(errors, {})) {
+ errors = null;
+ }
+ this.verificationForm.get('verificationCode').setErrors(errors);
+ }, 5000);
+ }
+ }
+ );
+ }
+ }
+
+ selectProvider(type: TwoFactorAuthProviderType) {
+ this.prevProvider = type === null ? this.selectedProvider : null;
+ this.selectedProvider = type;
+ this.showResendAction = 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
+ });
+ if (type !== TwoFactorAuthProviderType.TOTP && type !== TwoFactorAuthProviderType.BACKUP_CODE) {
+ this.sendCode();
+ this.showResendAction = 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});
+ }
+ }
+
+ sendCode($event?: Event) {
+ if ($event) {
+ $event.stopPropagation();
+ }
+ this.hideResendButton = true;
+ this.countDownTime = 0;
+ this.twoFactorAuthService.requestTwoFaVerificationCodeSend(this.selectedProvider).subscribe(() => {
+ this.countDownTime = this.minVerificationPeriod;
+ }, () => {
+ this.countDownTime = this.minVerificationPeriod;
+ });
+ }
+
+ cancelLogin() {
+ if (this.prevProvider) {
+ this.selectedProvider = this.prevProvider;
+ this.prevProvider = null;
+ } else {
+ this.authService.logout();
+ }
+ }
+
+ private updatedTime() {
+ if (this.countDownTime > 0) {
+ this.countDownTime--;
+ if (this.countDownTime === 0) {
+ this.hideResendButton = false;
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/shared/components/user-menu.component.html b/ui-ngx/src/app/shared/components/user-menu.component.html
index 5dfcebf3f7..ffe9cdc158 100644
--- a/ui-ngx/src/app/shared/components/user-menu.component.html
+++ b/ui-ngx/src/app/shared/components/user-menu.component.html
@@ -32,6 +32,10 @@
account_circle
home.profile
+
+ lock
+ security.security
+
exit_to_app
home.logout
diff --git a/ui-ngx/src/app/shared/components/user-menu.component.ts b/ui-ngx/src/app/shared/components/user-menu.component.ts
index 7388876896..00635597bc 100644
--- a/ui-ngx/src/app/shared/components/user-menu.component.ts
+++ b/ui-ngx/src/app/shared/components/user-menu.component.ts
@@ -106,6 +106,10 @@ export class UserMenuComponent implements OnInit, OnDestroy {
this.router.navigate(['profile']);
}
+ openSecurity(): void {
+ this.router.navigate(['security']);
+ }
+
logout(): void {
this.authService.logout();
}
diff --git a/ui-ngx/src/app/shared/models/authority.enum.ts b/ui-ngx/src/app/shared/models/authority.enum.ts
index 83dab9a8a8..683c153b07 100644
--- a/ui-ngx/src/app/shared/models/authority.enum.ts
+++ b/ui-ngx/src/app/shared/models/authority.enum.ts
@@ -19,5 +19,6 @@ export enum Authority {
TENANT_ADMIN = 'TENANT_ADMIN',
CUSTOMER_USER = 'CUSTOMER_USER',
REFRESH_TOKEN = 'REFRESH_TOKEN',
- ANONYMOUS = 'ANONYMOUS'
+ ANONYMOUS = 'ANONYMOUS',
+ PRE_VERIFICATION_TOKEN = 'PRE_VERIFICATION_TOKEN'
}
diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts
index 29f6ccc020..2a01a7eae4 100644
--- a/ui-ngx/src/app/shared/models/constants.ts
+++ b/ui-ngx/src/app/shared/models/constants.ts
@@ -63,6 +63,7 @@ export const HelpLinks = {
smsProviderSettings: helpBaseUrl + '/docs/user-guide/ui/sms-provider-settings',
securitySettings: helpBaseUrl + '/docs/user-guide/ui/security-settings',
oauth2Settings: helpBaseUrl + '/docs/user-guide/oauth-2-support/',
+ twoFactorAuthSettings: helpBaseUrl + '/docs/',
ruleEngine: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/overview/',
ruleNodeCheckRelation: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#check-relation-filter-node',
ruleNodeCheckExistenceFields: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#check-existence-fields-node',
diff --git a/ui-ngx/src/app/shared/models/login.models.ts b/ui-ngx/src/app/shared/models/login.models.ts
index 782e3a98a4..d3f1598fc5 100644
--- a/ui-ngx/src/app/shared/models/login.models.ts
+++ b/ui-ngx/src/app/shared/models/login.models.ts
@@ -14,6 +14,8 @@
/// limitations under the License.
///
+import { Authority } from '@shared/models/authority.enum';
+
export interface LoginRequest {
username: string;
password: string;
@@ -26,4 +28,5 @@ export interface PublicLoginRequest {
export interface LoginResponse {
token: string;
refreshToken: string;
+ scope?: Authority;
}
diff --git a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts
new file mode 100644
index 0000000000..10040e00e7
--- /dev/null
+++ b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts
@@ -0,0 +1,182 @@
+///
+/// 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.
+///
+
+export interface TwoFactorAuthSettings {
+ maxVerificationFailuresBeforeUserLockout: number;
+ providers: Array;
+ totalAllowedTimeForVerification: number;
+ useSystemTwoFactorAuthSettings: boolean;
+ verificationCodeCheckRateLimit: string;
+ minVerificationCodeSendPeriod: number;
+}
+
+export interface TwoFactorAuthSettingsForm extends TwoFactorAuthSettings{
+ providers: Array;
+ verificationCodeCheckRateLimitEnable: boolean;
+ verificationCodeCheckRateLimitNumber: number;
+ verificationCodeCheckRateLimitTime: number;
+}
+
+export type TwoFactorAuthProviderConfig = Partial;
+
+export type TwoFactorAuthProviderConfigForm = Partial & TwoFactorAuthProviderFormConfig;
+
+export interface TotpTwoFactorAuthProviderConfig {
+ providerType: TwoFactorAuthProviderType;
+ issuerName: string;
+}
+
+export interface SmsTwoFactorAuthProviderConfig {
+ providerType: TwoFactorAuthProviderType;
+ smsVerificationMessageTemplate: string;
+ verificationCodeLifetime: number;
+}
+
+export interface EmailTwoFactorAuthProviderConfig {
+ providerType: TwoFactorAuthProviderType;
+ verificationCodeLifetime: number;
+}
+
+export interface TwoFactorAuthProviderFormConfig {
+ enable: boolean;
+}
+
+export enum TwoFactorAuthProviderType{
+ TOTP = 'TOTP',
+ SMS = 'SMS',
+ EMAIL = 'EMAIL',
+ BACKUP_CODE = 'BACKUP_CODE'
+}
+
+interface GeneralTwoFactorAuthAccountConfig {
+ providerType: TwoFactorAuthProviderType;
+ useByDefault: boolean;
+}
+
+export interface TotpTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig {
+ authUrl: string;
+}
+
+export interface SmsTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig {
+ phoneNumber: string;
+}
+
+export interface EmailTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig {
+ email: string;
+}
+
+export interface BackupCodeTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig {
+ codesLeft: number;
+ codes?: Array;
+}
+
+export type TwoFactorAuthAccountConfig = TotpTwoFactorAuthAccountConfig | SmsTwoFactorAuthAccountConfig |
+ EmailTwoFactorAuthAccountConfig | BackupCodeTwoFactorAuthAccountConfig;
+
+
+export interface AccountTwoFaSettings {
+ configs: {TwoFactorAuthProviderType: TwoFactorAuthAccountConfig};
+}
+
+export interface TwoFaProviderInfo {
+ type: TwoFactorAuthProviderType;
+ default: boolean;
+ contact?: string;
+ minVerificationCodeSendPeriod?: number;
+}
+
+export interface TwoFactorAuthProviderData {
+ name: string;
+ description: string;
+ activatedHint: string;
+}
+
+export interface TwoFactorAuthProviderLoginData extends Omit {
+ icon: string;
+ placeholder: string;
+}
+
+export const twoFactorAuthProvidersData = new Map(
+ [
+ [
+ TwoFactorAuthProviderType.TOTP, {
+ name: 'security.2fa.provider.totp',
+ description: 'security.2fa.provider.totp-description',
+ activatedHint: 'security.2fa.provider.totp-hint'
+ }
+ ],
+ [
+ TwoFactorAuthProviderType.SMS, {
+ name: 'security.2fa.provider.sms',
+ description: 'security.2fa.provider.sms-description',
+ activatedHint: 'security.2fa.provider.sms-hint'
+ }
+ ],
+ [
+ TwoFactorAuthProviderType.EMAIL, {
+ name: 'security.2fa.provider.email',
+ description: 'security.2fa.provider.email-description',
+ activatedHint: 'security.2fa.provider.email-hint'
+ }
+ ],
+ [
+ TwoFactorAuthProviderType.BACKUP_CODE, {
+ name: 'security.2fa.provider.backup_code',
+ description: 'security.2fa.provider.backup-code-description',
+ activatedHint: 'security.2fa.provider.backup-code-hint'
+ }
+ ]
+ ]
+);
+
+export const twoFactorAuthProvidersLoginData = new Map(
+ [
+ [
+ TwoFactorAuthProviderType.TOTP, {
+ 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',
+ placeholder: 'login.sms-auth-placeholder',
+ icon: 'mdi:message-reply-text-outline'
+ }
+ ],
+ [
+ TwoFactorAuthProviderType.EMAIL, {
+ 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'
+ }
+ ]
+ ]
+);
diff --git a/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json b/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json
index dd4b9cc0b0..a16b28d561 100644
--- a/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json
+++ b/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.",
diff --git a/ui-ngx/src/assets/locale/locale.constant-el_GR.json b/ui-ngx/src/assets/locale/locale.constant-el_GR.json
index 6bd8168418..b66e02ba26 100644
--- a/ui-ngx/src/assets/locale/locale.constant-el_GR.json
+++ b/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}} νέες οντότητες δημιουργήθηκαν με επιτυχία.",
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json
index ff1f85b1f0..2f05d58e94 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/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",
@@ -319,7 +321,40 @@
"queue-partitions": "Partitions",
"queue-submit-strategy": "Submit strategy",
"queue-processing-strategy": "Processing strategy",
- "queue-configuration": "Queue configuration"
+ "queue-configuration": "Queue configuration",
+ "2fa": {
+ "2fa": "Two-factor authentication",
+ "available-providers": "Available providers",
+ "issuer-name": "Issuer name",
+ "issuer-name-required": "Issuer name is required.",
+ "max-verification-failures-before-user-lockout": "Max verification failures before user lockout",
+ "max-verification-failures-before-user-lockout-pattern": "Max verification failures must be a positive integer.",
+ "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",
+ "retry-verification-code-period-required": "Retry verification code period is required.",
+ "total-allowed-time-for-verification": "Total allowed time for verification (sec)",
+ "total-allowed-time-for-verification-pattern": "Minimal total allowed time is 60 sec",
+ "total-allowed-time-for-verification-required": "Total allowed time is required.",
+ "use-system-two-factor-auth-settings": "Use system two factor auth settings",
+ "verification-code-check-rate-limit": "Verification code check rate limit",
+ "verification-code-lifetime": "Verification code lifetime (sec)",
+ "verification-code-lifetime-pattern": "Verification code lifetime must be a positive integer.",
+ "verification-code-lifetime-required": "Verification code lifetime is required.",
+ "verification-message-template": "Verification message template",
+ "verification-limitations": "Verification limitations",
+ "verification-message-template-pattern": "Verification message need to contains pattern: ${code}",
+ "verification-message-template-required": "Verification message template is required.",
+ "within-time": "Within time (sec)",
+ "within-time-pattern": "Time must be a positive integer.",
+ "within-time-required": "Time is required."
+ }
},
"alarm": {
"alarm": "Alarm",
@@ -2393,8 +2428,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.",
@@ -2454,6 +2488,7 @@
"request-password-reset": "Request Password Reset",
"reset-password": "Reset Password",
"create-password": "Create Password",
+ "two-factor-authentication": "Two factor authentication",
"passwords-mismatch-error": "Entered passwords must be same!",
"password-again": "Password again",
"sign-in": "Please sign in",
@@ -2468,7 +2503,20 @@
"email": "Email",
"login-with": "Login with {{name}}",
"or": "or",
- "error": "Login error"
+ "error": "Login error",
+ "verify-your-identity": "Verify your identity",
+ "select-way-to-verify": "Select a way to verify",
+ "resend-code": "Resend code",
+ "resend-code-wait": "Resend code in { 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}}.",
+ "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": {
"edit": "Edit",
@@ -2557,6 +2605,63 @@
"tokenCopiedSuccessMessage": "JWT token has been copied to clipboard",
"tokenCopiedWarnMessage": "JWT token is expired! Please, refresh the page."
},
+ "security": {
+ "security": "Security",
+ "2fa": {
+ "2fa": "Two-factor authentication",
+ "2fa-description": "Two-factor authentication protects your account from unauthorized access. All you have to do is enter a security code when you log in.",
+ "authenticate-with": "You can authenticate with:",
+ "disable-2fa-provider-text": "Disabling {{name}} will make your account less secure",
+ "disable-2fa-provider-title": "Are you sure you want to disable {{name}}?",
+ "get-new-code": "Get new code",
+ "main-2fa-method": "Use as main two-factor authentication method",
+ "dialog": {
+ "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",
+ "sms-step-description": "Enter a phone number to use as your authenticator.",
+ "sms-step-label": "Phone Number",
+ "success": "Success!",
+ "totp-step-description-install": "You can install apps like Google Authenticator, Authy, or Duo.",
+ "totp-step-description-open": "Open the authenticator app on your mobile phone.",
+ "totp-step-label": "Get app",
+ "verification-code": "6-digit code",
+ "verification-code-invalid": "Invalid verification code format",
+ "verification-code-incorrect": "Verification code is incorrect",
+ "verification-code-many-request": "Too many requests check verification code",
+ "verification-step-description": "Enter a 6-digit code we just sent to {{address}}",
+ "verification-step-label": "Verification"
+ },
+ "provider": {
+ "email": "Email",
+ "email-description": "Use a security code sent to your email address to authenticate.",
+ "email-hint": "Authentication codes are sent via email to {{ info }}",
+ "sms": "SMS",
+ "sms-description": "Use your phone to authenticate. We'll send you a security code via SMS message when you log in.",
+ "sms-hint": "Authentication codes are sent by text message to {{ info }}",
+ "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-hint": "Authenticator app is set up for your account",
+ "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.",
+ "backup-code-hint": "{{ info }} single-use codes are active at this time"
+ }
+ }
+ },
"relation": {
"relations": "Relations",
"direction": "Direction",
diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json
index caadecc9ae..f8e16aca0e 100644
--- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json
+++ b/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.",
diff --git a/ui-ngx/src/assets/locale/locale.constant-ka_GE.json b/ui-ngx/src/assets/locale/locale.constant-ka_GE.json
index fe1d1ff793..c33964e3b9 100644
--- a/ui-ngx/src/assets/locale/locale.constant-ka_GE.json
+++ b/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}} ახალი ობიექტები წარმატებით შეიქმნა.",
diff --git a/ui-ngx/src/assets/locale/locale.constant-ko_KR.json b/ui-ngx/src/assets/locale/locale.constant-ko_KR.json
index de3ade448a..e56925bbed 100644
--- a/ui-ngx/src/assets/locale/locale.constant-ko_KR.json
+++ b/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}}개의 새로운 개체사 성공적으로 생성되었습니다.",
diff --git a/ui-ngx/src/assets/locale/locale.constant-lv_LV.json b/ui-ngx/src/assets/locale/locale.constant-lv_LV.json
index d963a04243..77ca2300b5 100644
--- a/ui-ngx/src/assets/locale/locale.constant-lv_LV.json
+++ b/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.",
diff --git a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json
index 1c84ed3c16..a71cca0e39 100644
--- a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json
+++ b/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.",
diff --git a/ui-ngx/src/assets/locale/locale.constant-ro_RO.json b/ui-ngx/src/assets/locale/locale.constant-ro_RO.json
index fc00f9bd50..01af73ece5 100644
--- a/ui-ngx/src/assets/locale/locale.constant-ro_RO.json
+++ b/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",
diff --git a/ui-ngx/src/assets/locale/locale.constant-ru_RU.json b/ui-ngx/src/assets/locale/locale.constant-ru_RU.json
index 2a6127f672..a4e894e59e 100644
--- a/ui-ngx/src/assets/locale/locale.constant-ru_RU.json
+++ b/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}} новый(х) объект(ов) было успешно создано.",
diff --git a/ui-ngx/src/assets/locale/locale.constant-sl_SI.json b/ui-ngx/src/assets/locale/locale.constant-sl_SI.json
index 1b7275dc0e..62603b1408 100644
--- a/ui-ngx/src/assets/locale/locale.constant-sl_SI.json
+++ b/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.",
diff --git a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json
index 76d8c99709..0c983e8968 100644
--- a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json
+++ b/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.",
diff --git a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json
index 91039b302b..eff83577fd 100644
--- a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json
+++ b/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}} нову(их) сутність(ей) успішно створено.",