diff --git a/ui-ngx/src/app/core/http/two-factor-authentication.service.ts b/ui-ngx/src/app/core/http/two-factor-authentication.service.ts index 36183db9d8..c2a7a5235d 100644 --- a/ui-ngx/src/app/core/http/two-factor-authentication.service.ts +++ b/ui-ngx/src/app/core/http/two-factor-authentication.service.ts @@ -39,8 +39,8 @@ export class TwoFactorAuthenticationService { return this.http.get(`/api/2fa/settings`, defaultHttpOptionsFromConfig(config)); } - saveTwoFaSettings(settings: TwoFactorAuthSettings, config?: RequestConfig): Observable { - return this.http.post(`/api/2fa/settings`, settings, defaultHttpOptionsFromConfig(config)); + saveTwoFaSettings(settings: TwoFactorAuthSettings, config?: RequestConfig): Observable { + return this.http.post(`/api/2fa/settings`, settings, defaultHttpOptionsFromConfig(config)); } getAvailableTwoFaProviders(config?: RequestConfig): Observable> { @@ -67,8 +67,9 @@ export class TwoFactorAuthenticationService { } verifyAndSaveTwoFaAccountConfig(authConfig: TwoFactorAuthAccountConfig, verificationCode: number, - config?: RequestConfig): Observable { - return this.http.post(`/api/2fa/account/config?verificationCode=${verificationCode}`, authConfig, defaultHttpOptionsFromConfig(config)); + config?: RequestConfig): Observable { + return this.http.post(`/api/2fa/account/config?verificationCode=${verificationCode}`, + authConfig, defaultHttpOptionsFromConfig(config)); } deleteTwoFaAccountConfig(providerType: TwoFactorAuthProviderType, config?: RequestConfig): Observable { @@ -76,4 +77,8 @@ export class TwoFactorAuthenticationService { defaultHttpOptionsFromConfig(config)); } + requestTwoFaVerificationCodeSend(providerType: TwoFactorAuthProviderType, config?: RequestConfig) { + return this.http.post(`/api/auth/2fa/verification/send?providerType=${providerType}`, defaultHttpOptionsFromConfig(config)); + } + } 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/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.html b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.html deleted file mode 100644 index 46710e6ba0..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.html +++ /dev/null @@ -1,104 +0,0 @@ - - -

Enable email authenticator

- - -
- - -
-
- - - done - - - Add email -
- - user.email - - - {{ 'user.email-required' | translate }} - - - {{ 'user.invalid-email-format' | translate }} - - -
- - -
-
-
- - Authentication verification -
-

Enter the 6-digit code here

-

Enter the 6 digit verification code we just sent to {{ emailConfigForm.get('email').value }}.

- - 6-digit code - - -
- - -
-
-
- - Two-factor authentication activated -
-

Email authentication enabled

-

If we notice an attempted login from a device or browser we don’t recognize, you will be prompted to enter the security code that will be sent to your email address.

-
- -
-
-
-
-
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html deleted file mode 100644 index 03c6c7a465..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html +++ /dev/null @@ -1,105 +0,0 @@ - - -

Enable SMS authenticator

- - -
- - -
-
- - - done - - - Add phone number -
-

Enter the phone number including the area code.

- - Phone number - - - {{ 'admin.number-to-required' | translate }} - - - {{ 'admin.phone-number-pattern' | translate }} - - -
- - -
-
-
- - Authentication verification -
-

Enter the 6-digit code here

-

Enter the 6 digit verification code we just sent to {{ smsConfigForm.get('phone').value }}.

- - 6-digit code - - -
- - -
-
-
- - Two-factor authentication activated -
-

SMS authentication enabled

-

If we notice an attempted login from a device or browser we don’t recognize, you will be prompted to enter the security code that will be sent to the phone number.

-
- -
-
-
-
-
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss deleted file mode 100644 index ec6f008a80..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 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/authentication-dialog/totp-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.html deleted file mode 100644 index 09afbb2de0..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.html +++ /dev/null @@ -1,97 +0,0 @@ - - -

Enable authenticator app

- - -
- - -
-
- - - done - - - Get app -
-

You'll need to use a verification app such as Google Authenticator, Authy, or Duo. Install from your app store

-
- - -
-
-
- - Authentication verification -
-

1. Scan this QR code with your verification app

-

Once your app reads the QR code, you'll get a 6-digit code.

- -

2. Enter the 6-digit code here

-

Enter the code from the app below. Once connected, we'll remember your phone so you can use it each time you log in.

- - 6-digit code - - -
- - -
-
-
- - Two-factor authentication activated -
-

Success

-

The next time you login from an unrecognized browser or device, you will need to provide a two-factor authentication code.

-
- -
-
-
-
-
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss deleted file mode 100644 index ec6f008a80..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 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-routing.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts index e7dae87288..440db606fd 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts @@ -26,8 +26,6 @@ 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 { @@ -42,17 +40,6 @@ export class UserProfileResolver implements Resolve { } } -@Injectable() -export class UserTwoFAProvidersResolver implements Resolve> { - - constructor(private twoFactorAuthService: TwoFactorAuthenticationService) { - } - - resolve(): Observable> { - return this.twoFactorAuthService.getAvailableTwoFaProviders(); - } -} - const routes: Routes = [ { path: 'profile', @@ -67,8 +54,7 @@ const routes: Routes = [ } }, resolve: { - user: UserProfileResolver, - providers: UserTwoFAProvidersResolver + user: UserProfileResolver } } ]; @@ -77,8 +63,7 @@ const routes: Routes = [ imports: [RouterModule.forChild(routes)], exports: [RouterModule], providers: [ - UserProfileResolver, - UserTwoFAProvidersResolver + UserProfileResolver ] }) export class ProfileRoutingModule { } 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 a1e9ec0523..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 : '' }}
-
+
profile.last-login-time
@@ -77,21 +78,23 @@ {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} -
- -
-
- - {{ expirationJwtData }} +
+
+ +
+
+ +
{{ 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 f3f41c41d8..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 { @@ -59,21 +61,4 @@ } } } - .description { - padding-bottom: 8px; - color: #808080; - margin-right: 8px; - } - - .provider { - padding: 14px 0; - - .mat-h3 { - margin-bottom: 8px; - } - - .checkbox-label { - font-size: 14px; - } - } } 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 0c676aca46..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 @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, OnInit, ViewChild } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { UserService } from '@core/http/user.service'; import { AuthUser, User } from '@shared/models/user.model'; import { Authority } from '@shared/models/authority.enum'; @@ -37,12 +37,6 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; 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, TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; -import { MatSlideToggle } from '@angular/material/slide-toggle'; -import { TotpAuthDialogComponent } from '@home/pages/profile/authentication-dialog/totp-auth-dialog.component'; -import { SMSAuthDialogComponent } from '@home/pages/profile/authentication-dialog/sms-auth-dialog.component'; -import { EmailAuthDialogComponent, } from '@home/pages/profile/authentication-dialog/email-auth-dialog.component'; @Component({ selector: 'tb-profile', @@ -53,25 +47,8 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir authorities = Authority; profile: FormGroup; - twoFactorAuth: FormGroup; user: User; languageList = env.supportedLangs; - allowTwoFactorAuth = false; - allowSMS2faProvider = false; - allowTOTP2faProvider = false; - allowEmail2faProvider = false; - twoFactorAuthProviderType = TwoFactorAuthProviderType; - - @ViewChild('totp') totp: MatSlideToggle; - - private authDialogMap = new Map( -[ - [TwoFactorAuthProviderType.TOTP, TotpAuthDialogComponent], - [TwoFactorAuthProviderType.SMS, SMSAuthDialogComponent], - [TwoFactorAuthProviderType.EMAIL, EmailAuthDialogComponent] - ] - ); - private readonly authUser: AuthUser; get jwtToken(): string { @@ -92,7 +69,6 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir private userService: UserService, private authService: AuthService, private translate: TranslateService, - private twoFaService: TwoFactorAuthenticationService, public dialog: MatDialog, public dialogService: DialogService, public fb: FormBuilder, @@ -104,9 +80,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir ngOnInit() { this.buildProfileForm(); - this.buildTwoFactorForm(); this.userLoaded(this.route.snapshot.data.user); - this.twoFactorLoad(this.route.snapshot.data.providers); } private buildProfileForm() { @@ -120,19 +94,6 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir }); } - private buildTwoFactorForm() { - this.twoFactorAuth = this.fb.group({ - TOTP: [false], - SMS: [false], - EMAIL: [false], - useByDefault: [null] - }); - this.twoFactorAuth.get('useByDefault').valueChanges.subscribe(value => { - this.twoFaService.updateTwoFaAccountConfig(value, true, {ignoreLoading: true}) - .subscribe(data => this.processTwoFactorAuthConfig(data)); - }); - } - save(): void { this.user = {...this.user, ...this.profile.value}; if (!this.user.additionalInfo) { @@ -190,37 +151,6 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir this.profile.get('homeDashboardHideToolbar').setValue(homeDashboardHideToolbar); } - private twoFactorLoad(providers: TwoFactorAuthProviderType[]) { - if (providers.length) { - this.allowTwoFactorAuth = true; - this.twoFaService.getAccountTwoFaSettings().subscribe(data => this.processTwoFactorAuthConfig(data)); - providers.forEach(provider => { - switch (provider) { - case TwoFactorAuthProviderType.SMS: - this.allowSMS2faProvider = true; - break; - case TwoFactorAuthProviderType.TOTP: - this.allowTOTP2faProvider = true; - break; - case TwoFactorAuthProviderType.EMAIL: - this.allowEmail2faProvider = true; - break; - } - }); - } - } - - private processTwoFactorAuthConfig(setting?: AccountTwoFaSettings) { - if (setting) { - Object.values(setting.configs).forEach(config => { - this.twoFactorAuth.get(config.providerType).setValue(true); - if (config.useByDefault) { - this.twoFactorAuth.get('useByDefault').setValue(config.providerType, {emitEvent: false}); - } - }); - } - } - confirmForm(): FormGroup { return this.profile; } @@ -249,31 +179,4 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir })); } } - - confirm2FAChange(event: MouseEvent, provider: TwoFactorAuthProviderType) { - event.stopPropagation(); - event.preventDefault(); - const providerName = provider === TwoFactorAuthProviderType.TOTP ? 'authenticator app' : `${provider.toLowerCase()} authentication`; - if (this.twoFactorAuth.get(provider).value) { - this.dialogService.confirm(`Are you sure you want to disable ${providerName}?`, - `Disabling ${providerName} will make your account less secure`).subscribe(res => { - if (res) { - this.twoFaService.deleteTwoFaAccountConfig(provider).subscribe(data => this.processTwoFactorAuthConfig(data)); - } - }); - } else { - this.dialog.open(this.authDialogMap.get(provider), { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - data: { - email: this.user.email - } - }).afterClosed().subscribe(res => { - if (res) { - this.twoFactorAuth.get(provider).setValue(res); - this.twoFactorAuth.get('useByDefault').setValue(provider, {emitEvent: false}); - } - }); - } - } } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts index 5210158537..4a8bcab074 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts @@ -20,17 +20,11 @@ import { ProfileComponent } from './profile.component'; import { SharedModule } from '@shared/shared.module'; import { ProfileRoutingModule } from './profile-routing.module'; import { ChangePasswordDialogComponent } from '@modules/home/pages/profile/change-password-dialog.component'; -import { TotpAuthDialogComponent } from './authentication-dialog/totp-auth-dialog.component'; -import { SMSAuthDialogComponent } from '@home/pages/profile/authentication-dialog/sms-auth-dialog.component'; -import { EmailAuthDialogComponent } from '@home/pages/profile/authentication-dialog/email-auth-dialog.component'; @NgModule({ declarations: [ ProfileComponent, - ChangePasswordDialogComponent, - TotpAuthDialogComponent, - SMSAuthDialogComponent, - EmailAuthDialogComponent + ChangePasswordDialogComponent ], imports: [ CommonModule, 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..9b8b17b102 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss @@ -0,0 +1,77 @@ +/** + * 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. + */ +: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; + } + } + + .input-container { + max-width: 290px; + } + + .code-container { + max-width: 170px; + } + + .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; + } + + & ::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..503fdd69a6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts @@ -0,0 +1,29 @@ +/// +/// 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'; + +export const authenticationDialogMap = new Map>( + [ + [TwoFactorAuthProviderType.TOTP, TotpAuthDialogComponent], + [TwoFactorAuthProviderType.SMS, SMSAuthDialogComponent], + [TwoFactorAuthProviderType.EMAIL, EmailAuthDialogComponent] + ] +); 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..d591dc7261 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html @@ -0,0 +1,102 @@ + + +

security.2fa.dialog.enable-email-title

+ + +
+ + +
+
+ + + done + + + {{ 'security.2fa.dialog.email-step-label' | translate }} +
+

security.2fa.dialog.email-step-description

+
+ + + + + {{ 'user.email-required' | translate }} + + + {{ 'user.invalid-email-format' | translate }} + + + +
+
+
+ + {{ 'security.2fa.dialog.verification-step-label' | translate }} +
+

+ {{ 'security.2fa.dialog.verification-step-description' | translate : {address: emailConfigForm.get('email').value} }} +

+
+ + + + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + + +
+
+
+ + {{ 'security.2fa.dialog.activation-step-label' | translate }} +
+

security.2fa.dialog.success

+

security.2fa.dialog.activation-step-description-email

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts similarity index 71% rename from ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.ts rename to ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts index be5976c78b..166ff799b7 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts @@ -32,7 +32,7 @@ export interface EmailAuthDialogData { @Component({ selector: 'tb-email-auth-dialog', templateUrl: './email-auth-dialog.component.html', - styleUrls: ['./email-auth-dialog.component.scss'] + styleUrls: ['./authentication-dialog.component.scss'] }) export class EmailAuthDialogComponent extends DialogComponent { @@ -68,20 +68,28 @@ export class EmailAuthDialogComponent extends DialogComponent { - this.stepper.next(); - }); + 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: - this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, - this.emailVerificationForm.get('verificationCode').value).subscribe(() => { - this.stepper.next(); - }); + if (this.emailVerificationForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.emailVerificationForm.get('verificationCode').value).subscribe(() => { + this.stepper.next(); + }); + } else { + this.showFormErrors(this.emailVerificationForm); + } break; } } @@ -90,4 +98,10 @@ export class EmailAuthDialogComponent extends DialogComponent 1); } + 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..61b093dd00 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html @@ -0,0 +1,105 @@ + + +

security.2fa.dialog.enable-sms-title

+ + +
+ + +
+
+ + + done + + + {{ 'security.2fa.dialog.sms-step-label' | translate }} +
+

security.2fa.dialog.sms-step-description

+
+ + + + + {{ 'admin.number-to-required' | translate }} + + + {{ 'admin.phone-number-pattern' | translate }} + + + + +
+
+
+ + {{ 'security.2fa.dialog.verification-step-label' | translate }} +
+

+ {{ 'security.2fa.dialog.verification-step-description' | translate : {address: smsConfigForm.get('phone').value} }} +

+
+ + + + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + + +
+
+
+ + {{ 'security.2fa.dialog.activation-step-label' | translate }} +
+

security.2fa.dialog.success

+

security.2fa.dialog.activation-step-description-sms

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts similarity index 71% rename from ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.ts rename to ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts index ed707922eb..030b67fd7a 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts @@ -29,7 +29,7 @@ import { MatStepper } from '@angular/material/stepper'; @Component({ selector: 'tb-sms-auth-dialog', templateUrl: './sms-auth-dialog.component.html', - styleUrls: ['./sms-auth-dialog.component.scss'] + styleUrls: ['./authentication-dialog.component.scss'] }) export class SMSAuthDialogComponent extends DialogComponent { @@ -66,20 +66,28 @@ export class SMSAuthDialogComponent extends DialogComponent { - this.stepper.next(); - }); + 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: - this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, - this.smsVerificationForm.get('verificationCode').value).subscribe(() => { - this.stepper.next(); - }); + if (this.smsVerificationForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.smsVerificationForm.get('verificationCode').value).subscribe(() => { + this.stepper.next(); + }); + } else { + this.showFormErrors(this.smsVerificationForm); + } break; } } @@ -88,4 +96,10 @@ export class SMSAuthDialogComponent extends DialogComponent 1); } + 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..6de8259011 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html @@ -0,0 +1,92 @@ + + +

security.2fa.dialog.enable-totp-title

+ + +
+ + +
+
+ + + 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.verification-step-label' | translate }} +
+

security.2fa.dialog.scan-qr-code

+ +

security.2fa.dialog.enter-verification-code

+ + + + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + +
+
+ +
+
+ + {{ 'security.2fa.dialog.activation-step-label' | translate }} +
+

security.2fa.dialog.success

+

security.2fa.dialog.activation-step-description-totp

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts similarity index 85% rename from ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.ts rename to ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts index 86b596ac2b..52aab77b57 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts @@ -28,7 +28,7 @@ import { MatStepper } from '@angular/material/stepper'; @Component({ selector: 'tb-totp-auth-dialog', templateUrl: './totp-auth-dialog.component.html', - styleUrls: ['./totp-auth-dialog.component.scss'] + styleUrls: ['./authentication-dialog.component.scss'] }) export class TotpAuthDialogComponent extends DialogComponent { @@ -67,10 +67,17 @@ export class TotpAuthDialogComponent extends DialogComponent { - this.stepper.next(); - }); + if (this.totpConfigForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.totpConfigForm.get('verificationCode').value).subscribe(() => { + this.stepper.next(); + }); + } else { + Object.keys(this.totpConfigForm.controls).forEach(field => { + const control = this.totpConfigForm.get(field); + control.markAsTouched({onlySelf: true}); + }); + } } closeDialog() { 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..19d414535e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html @@ -0,0 +1,80 @@ + +
+ + +
+
+ security.security +
+
+ profile.last-login-time + +
+
+
+ +
+ +
{{ expirationJwtData }}
+
+
+
+ + + admin.2fa.2fa + + +
security.2fa.2fa-description
+
+ +

security.2fa.authenticate-with

+
+
+ + +
+

{{ providersData.get(provider).name | translate }}

+
+
+ {{ providersData.get(provider).description | translate }} +
+ + +
+ + security.2fa.main-2fa-method + +
+ +
+
+
+
+
+
+
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..b14b2ebd18 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.scss @@ -0,0 +1,115 @@ +/** + * 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%; + } + .title-container { + margin: 0; + } + .profile-email { + font-size: 16px; + font-weight: 400; + } + .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; + } + .tb-home-dashboard { + tb-dashboard-autocomplete { + @media #{$mat-gt-sm} { + padding-right: 12px; + } + + @media #{$mat-lt-md} { + padding-bottom: 12px; + } + } + mat-checkbox { + @media #{$mat-gt-sm} { + margin-top: 16px; + } + } + } + } + + .description { + color: rgba(0, 0, 0, 0.54); + letter-spacing: 0.25px; + margin-right: 8px; + } + + .auth-title { + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + margin: 0; + } + + .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-radio-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..d5ded5f4f8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.ts @@ -0,0 +1,171 @@ +/// +/// 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, 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, + twoFactorAuthProvidersData, + TwoFactorAuthProviderType +} from '@shared/models/two-factor-auth.models'; +import { authenticationDialogMap } from '@home/pages/security/authentication-dialog/authentication-dialog.map'; + +@Component({ + selector: 'tb-security', + templateUrl: './security.component.html', + styleUrls: ['./security.component.scss'] +}) +export class SecurityComponent extends PageComponent implements OnInit { + + twoFactorAuth: FormGroup; + user: User; + allowTwoFactorProviders: TwoFactorAuthProviderType[] = []; + providersData = twoFactorAuthProvidersData; + + 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); + } + + private buildTwoFactorForm() { + this.twoFactorAuth = this.fb.group({ + TOTP: [false], + SMS: [false], + EMAIL: [false], + useByDefault: [null] + }); + this.twoFactorAuth.get('useByDefault').valueChanges.subscribe(value => { + this.twoFaService.updateTwoFaAccountConfig(value, true, {ignoreLoading: true}) + .subscribe(data => this.processTwoFactorAuthConfig(data)); + }); + } + + 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) { + const configs = setting.configs; + Object.values(TwoFactorAuthProviderType).forEach(provider => { + if (configs[provider]) { + this.twoFactorAuth.get(provider).setValue(true); + if (configs[provider].useByDefault) { + this.twoFactorAuth.get('useByDefault').setValue(provider, {emitEvent: false}); + } + } 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).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.twoFaService.deleteTwoFaAccountConfig(provider).subscribe(data => this.processTwoFactorAuthConfig(data)); + } + }); + } else { + const dialogData = provider === TwoFactorAuthProviderType.EMAIL ? {email: this.user.email} : {}; + this.dialog.open(authenticationDialogMap.get(provider), { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: dialogData + }).afterClosed().subscribe(res => { + if (res) { + this.twoFactorAuth.get(provider).setValue(res); + this.twoFactorAuth.get('useByDefault').setValue(provider, {emitEvent: false}); + } + }); + } + } +} 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..2df54747a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.module.ts @@ -0,0 +1,39 @@ +/// +/// 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'; + +@NgModule({ + declarations: [ + SecurityComponent, + TotpAuthDialogComponent, + SMSAuthDialogComponent, + EmailAuthDialogComponent + ], + imports: [ + CommonModule, + SharedModule, + SecurityRoutingModule + ] +}) +export class SecurityModule { } 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 +