Browse Source

UI: oauth2 configuration breakdown: initial implementation

pull/11231/head
rusikv 2 years ago
parent
commit
7a2a194074
  1. 10
      ui-ngx/src/app/core/auth/auth.service.ts
  2. 56
      ui-ngx/src/app/core/http/domain.service.ts
  3. 27
      ui-ngx/src/app/core/http/mobile-app.service.ts
  4. 44
      ui-ngx/src/app/core/http/mobile-application.service.ts
  5. 29
      ui-ngx/src/app/core/http/oauth2.service.ts
  6. 25
      ui-ngx/src/app/core/services/menu.service.ts
  7. 23
      ui-ngx/src/app/modules/home/components/entity/entities-table.component.html
  8. 4
      ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts
  9. 2
      ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts
  10. 4
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  11. 4
      ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts
  12. 14
      ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts
  13. 38
      ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
  14. 6
      ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
  15. 4
      ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts
  16. 614
      ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.html
  17. 78
      ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.scss
  18. 613
      ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.ts
  19. 22
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-chips.component.html
  20. 34
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-chips.component.scss
  21. 40
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-chips.component.ts
  22. 49
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-dialog.component.html
  23. 80
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-dialog.component.ts
  24. 53
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-list.component.html
  25. 182
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-list.component.ts
  26. 22
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-table-header.component.html
  27. 34
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-table-header.component.ts
  28. 331
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html
  29. 36
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.scss
  30. 377
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.ts
  31. 94
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/clients-table-config.resolver.ts
  32. 142
      ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts
  33. 21
      ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-header.component.html
  34. 33
      ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-header.component.ts
  35. 68
      ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html
  36. 105
      ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.ts
  37. 138
      ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-config.resolver.ts
  38. 21
      ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-header.component.html
  39. 33
      ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-header.component.ts
  40. 69
      ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.html
  41. 108
      ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.ts
  42. 152
      ui-ngx/src/app/modules/home/pages/admin/oauth2/oauth2-routing.module.ts
  43. 50
      ui-ngx/src/app/modules/home/pages/admin/oauth2/oauth2.module.ts
  44. 6
      ui-ngx/src/app/modules/login/pages/login/login.component.ts
  45. 4
      ui-ngx/src/app/shared/models/constants.ts
  46. 62
      ui-ngx/src/app/shared/models/entity-type.models.ts
  47. 27
      ui-ngx/src/app/shared/models/id/domain-id.ts
  48. 27
      ui-ngx/src/app/shared/models/id/mobile-app-id.ts
  49. 27
      ui-ngx/src/app/shared/models/id/oauth2-client-id.ts
  50. 176
      ui-ngx/src/app/shared/models/oauth2.models.ts
  51. 44
      ui-ngx/src/assets/locale/locale.constant-en_US.json

10
ui-ngx/src/app/core/auth/auth.service.ts

@ -42,7 +42,7 @@ import { TimeService } from '@core/services/time.service';
import { UtilsService } from '@core/services/utils.service';
import { MatDialog, MatDialogConfig } from '@angular/material/dialog';
import { AlertDialogComponent } from '@shared/components/dialog/alert-dialog.component';
import { OAuth2ClientInfo, PlatformType } from '@shared/models/oauth2.models';
import { OAuth2ClientLoginInfo, PlatformType } from '@shared/models/oauth2.models';
import { isMobileApp } from '@core/utils';
import { TwoFactorAuthProviderType, TwoFaProviderInfo } from '@shared/models/two-factor-auth.models';
import { UserPasswordPolicy } from '@shared/models/settings.models';
@ -66,7 +66,7 @@ export class AuthService {
}
redirectUrl: string;
oauth2Clients: Array<OAuth2ClientInfo> = null;
oauth2Clients: Array<OAuth2ClientLoginInfo> = null;
twoFactorAuthProviders: Array<TwoFaProviderInfo> = null;
private refreshTokenSubject: ReplaySubject<LoginResponse> = null;
@ -223,9 +223,9 @@ export class AuthService {
}
}
public loadOAuth2Clients(): Observable<Array<OAuth2ClientInfo>> {
const url = '/api/noauth/oauth2Clients?platform=' + PlatformType.WEB;
return this.http.post<Array<OAuth2ClientInfo>>(url,
public loadOAuth2Clients(): Observable<Array<OAuth2ClientLoginInfo>> {
const url = '/api/noauth/oauth2/client?platform=' + PlatformType.WEB;
return this.http.post<Array<OAuth2ClientLoginInfo>>(url,
null, defaultHttpOptions()).pipe(
catchError(err => of([])),
tap((OAuth2Clients) => {

56
ui-ngx/src/app/core/http/domain.service.ts

@ -0,0 +1,56 @@
///
/// Copyright © 2016-2024 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 } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { Observable } from 'rxjs';
import { Domain, DomainInfo } from '@shared/models/oauth2.models';
import { PageLink } from '@shared/models/page/page-link';
import { PageData } from '@shared/models/page/page-data';
@Injectable({
providedIn: 'root'
})
export class DomainService {
constructor(
private http: HttpClient
) {
}
public saveDomain(domain: Domain, oauth2ClientIds: Array<string>, config?: RequestConfig): Observable<Domain> {
return this.http.post<Domain>(`/api/domain?oauth2ClientIds=${oauth2ClientIds.join(',')}`,
domain, defaultHttpOptionsFromConfig(config));
}
public updateOauth2Clients(id: string, oauth2ClientRegistrationIds: Array<string>, config?: RequestConfig): Observable<void> {
return this.http.put<void>(`/api/domain/${id}/oauth2Clients`, oauth2ClientRegistrationIds, defaultHttpOptionsFromConfig(config));
}
public getTenantDomainInfos(pageLink: PageLink, config?: RequestConfig): Observable<PageData<DomainInfo>> {
return this.http.get<PageData<DomainInfo>>(`/api/domain/infos${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public getDomainInfoById(id: string, config?: RequestConfig): Observable<DomainInfo> {
return this.http.get<DomainInfo>(`/api/domain/info/${id}`, defaultHttpOptionsFromConfig(config));
}
public deleteDomain(id: string, config?: RequestConfig): Observable<void> {
return this.http.delete<void>(`/api/domain/${id}`, defaultHttpOptionsFromConfig(config));
}
}

27
ui-ngx/src/app/core/http/mobile-app.service.ts

@ -14,11 +14,13 @@
/// limitations under the License.
///
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { Observable } from 'rxjs';
import { MobileAppSettings } from '@shared/models/mobile-app.models';
import { MobileApp, MobileAppInfo } from '@shared/models/oauth2.models';
import { PageLink } from '@shared/models/page/page-link';
import { PageData } from '@shared/models/page/page-data';
@Injectable({
providedIn: 'root'
@ -30,16 +32,25 @@ export class MobileAppService {
) {
}
public getMobileAppSettings(config?: RequestConfig): Observable<MobileAppSettings> {
return this.http.get<MobileAppSettings>(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config));
public saveMobileApp(mobileApp: MobileApp, oauth2ClientIds: Array<string>, config?: RequestConfig): Observable<MobileApp> {
return this.http.post<MobileApp>(`/api/mobileApp?oauth2ClientIds=${oauth2ClientIds.join(',')}`,
mobileApp, defaultHttpOptionsFromConfig(config));
}
public updateOauth2Clients(id: string, oauth2ClientRegistrationIds: Array<string>, config?: RequestConfig): Observable<void> {
return this.http.put<void>(`/api/mobileApp/${id}/oauth2Clients`, oauth2ClientRegistrationIds, defaultHttpOptionsFromConfig(config));
}
public getTenantMobileAppInfos(pageLink: PageLink, config?: RequestConfig): Observable<PageData<MobileAppInfo>> {
return this.http.get<PageData<MobileAppInfo>>(`/api/mobileApp/infos${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public saveMobileAppSettings(mobileAppSettings: MobileAppSettings, config?: RequestConfig): Observable<MobileAppSettings> {
return this.http.post<MobileAppSettings>(`/api/mobile/app/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config));
public getMobileAppInfoById(id: string, config?: RequestConfig): Observable<MobileAppInfo> {
return this.http.get<MobileAppInfo>(`/api/mobileApp/info/${id}`, defaultHttpOptionsFromConfig(config));
}
public getMobileAppDeepLink(config?: RequestConfig): Observable<string> {
return this.http.get<string>(`/api/mobile/deepLink`, defaultHttpOptionsFromConfig(config));
public deleteMobileApp(id: string, config?: RequestConfig): Observable<void> {
return this.http.delete<void>(`/api/mobileApp/${id}`, defaultHttpOptionsFromConfig(config));
}
}

44
ui-ngx/src/app/core/http/mobile-application.service.ts

@ -0,0 +1,44 @@
///
/// Copyright © 2016-2024 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 { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { Observable } from 'rxjs';
import { MobileAppSettings } from '@shared/models/mobile-app.models';
@Injectable({
providedIn: 'root'
})
export class MobileApplicationService {
constructor(
private http: HttpClient
) {}
public getMobileAppSettings(config?: RequestConfig): Observable<MobileAppSettings> {
return this.http.get<MobileAppSettings>(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config));
}
public saveMobileAppSettings(mobileAppSettings: MobileAppSettings, config?: RequestConfig): Observable<MobileAppSettings> {
return this.http.post<MobileAppSettings>(`/api/mobile/app/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config));
}
public getMobileAppDeepLink(config?: RequestConfig): Observable<string> {
return this.http.get<string>(`/api/mobile/deepLink`, defaultHttpOptionsFromConfig(config));
}
}

29
ui-ngx/src/app/core/http/oauth2.service.ts

@ -18,7 +18,9 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { Observable } from 'rxjs';
import { OAuth2ClientRegistrationTemplate, OAuth2Info } from '@shared/models/oauth2.models';
import { OAuth2Client, OAuth2ClientInfo, OAuth2ClientRegistrationTemplate } from '@shared/models/oauth2.models';
import { PageData } from '@shared/models/page/page-data';
import { PageLink } from '@shared/models/page/page-link';
@Injectable({
providedIn: 'root'
@ -27,22 +29,31 @@ export class OAuth2Service {
constructor(
private http: HttpClient
) { }
public getOAuth2Settings(config?: RequestConfig): Observable<OAuth2Info> {
return this.http.get<OAuth2Info>(`/api/oauth2/config`, defaultHttpOptionsFromConfig(config));
) {
}
public getOAuth2Template(config?: RequestConfig): Observable<Array<OAuth2ClientRegistrationTemplate>> {
return this.http.get<Array<OAuth2ClientRegistrationTemplate>>(`/api/oauth2/config/template`, defaultHttpOptionsFromConfig(config));
}
public saveOAuth2Settings(OAuth2Setting: OAuth2Info, config?: RequestConfig): Observable<OAuth2Info> {
return this.http.post<OAuth2Info>('/api/oauth2/config', OAuth2Setting,
defaultHttpOptionsFromConfig(config));
public saveOAuth2Client(oAuth2Client: OAuth2Client, config?: RequestConfig): Observable<OAuth2Client> {
return this.http.post<OAuth2Client>('/api/oauth2/client', oAuth2Client, defaultHttpOptionsFromConfig(config));
}
public findTenantOAuth2ClientInfos(pageLink: PageLink, config?: RequestConfig): Observable<PageData<OAuth2ClientInfo>> {
return this.http.get<PageData<OAuth2ClientInfo>>(`/api/oauth2/client/infos${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public getOAuth2ClientById(id: string, config?: RequestConfig): Observable<OAuth2Client> {
return this.http.get<OAuth2Client>(`/api/oauth2/client/${id}`, defaultHttpOptionsFromConfig(config));
}
public deleteOauth2Client(id: string, config?: RequestConfig): Observable<void> {
return this.http.delete<void>(`/api/oauth2/client/${id}`, defaultHttpOptionsFromConfig(config));
}
public getLoginProcessingUrl(config?: RequestConfig): Observable<string> {
return this.http.get<string>(`/api/oauth2/loginProcessingUrl`, defaultHttpOptionsFromConfig(config));
return this.http.get<string>('/api/oauth2/loginProcessingUrl', defaultHttpOptionsFromConfig(config));
}
}

25
ui-ngx/src/app/core/services/menu.service.ts

@ -282,7 +282,30 @@ export class MenuService {
name: 'admin.oauth2.oauth2',
type: 'link',
path: '/security-settings/oauth2',
icon: 'mdi:shield-account'
icon: 'mdi:shield-account',
pages: [
{
id: 'domains',
name: 'admin.oauth2.domains',
type: 'link',
path: '/security-settings/oauth2/domains',
icon: 'domain'
},
{
id: 'mobile_app',
name: 'admin.oauth2.mobile-apps',
type: 'link',
path: '/security-settings/oauth2/mobile-applications',
icon: 'smartphone'
},
{
id: 'clients',
name: 'admin.oauth2.clients',
type: 'link',
path: '/security-settings/oauth2/clients',
icon: 'public'
}
]
}
]
}

23
ui-ngx/src/app/modules/home/components/entity/entities-table.component.html

@ -38,7 +38,9 @@
<mat-toolbar class="mat-mdc-table-toolbar" [fxShow]="!textSearchMode && dataSource.selection.isEmpty()">
<div class="mat-toolbar-tools">
<div fxLayout="row" fxLayoutAlign="start center" fxLayout.xs="column" fxLayoutAlign.xs="center start" class="title-container">
<span *ngIf="entitiesTableConfig.tableTitle" class="tb-entity-table-title">{{ entitiesTableConfig.tableTitle }}</span>
<span *ngIf="!entitiesTableConfig.hideTableTitle && entitiesTableConfig.tableTitle" class="tb-entity-table-title">
{{ entitiesTableConfig.tableTitle }}
</span>
<tb-anchor #entityTableHeader></tb-anchor>
<tb-timewindow *ngIf="entitiesTableConfig.useTimePageLink" [(ngModel)]="timewindow"
(ngModelChange)="onTimewindowChange()"
@ -165,12 +167,19 @@
[matTooltip]="cellTooltip(entity, column, row)"
matTooltipPosition="above"
[ngStyle]="cellStyle(entity, column, row)">
<ng-container *ngIf="column.type == 'link' && column.entityURL(entity) as detailsPageURL; else defaultContent">
<a [routerLink]="detailsPageURL" [innerHTML]="cellContent(entity, column, row)" (click)="$event.stopPropagation();"></a>
<ng-container [ngSwitch]="column.type">
<ng-template [ngSwitchCase]="'link'">
<a *ngIf="column.entityURL(entity) as detailsPageURL; else defaultContent"
[routerLink]="detailsPageURL" [innerHTML]="cellContent(entity, column, row)"
(click)="$event.stopPropagation();"></a>
</ng-template>
<ng-template [ngSwitchCase]="'clients'">
<tb-client-chips [clientsEntity]="entity"></tb-client-chips>
</ng-template>
<ng-template #defaultContent ngSwitchDefault>
<span [innerHTML]="cellContent(entity, column, row)"></span>
</ng-template>
</ng-container>
<ng-template #defaultContent>
<span [innerHTML]="cellContent(entity, column, row)"></span>
</ng-template>
<ng-template [ngIf]="column.actionCell">
<ng-container [ngSwitch]="column.actionCell.type">
<ng-template [ngSwitchCase]="cellActionType.COPY_BUTTON">
@ -209,7 +218,7 @@
matTooltipPosition="above"
(click)="column.actionDescriptor.onAction($event, entity)">
<tb-icon [ngStyle]="column.actionDescriptor.style">
{{column.actionDescriptor.icon}}
{{column.actionDescriptor.iconFunction ? column.actionDescriptor.iconFunction(entity) : column.actionDescriptor.icon}}
</tb-icon>
</button>
</mat-cell>

4
ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts

@ -46,6 +46,7 @@ import { ActivatedRoute, QueryParamsHandling, Router } from '@angular/router';
import {
CellActionDescriptor,
CellActionDescriptorType,
ClientChipsEntityTableColumn,
EntityActionTableColumn,
EntityColumn,
EntityLinkTableColumn,
@ -614,7 +615,8 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa
columnsUpdated(resetData: boolean = false) {
this.entityColumns = this.entitiesTableConfig.columns.filter(
(column) => column instanceof EntityTableColumn || column instanceof EntityLinkTableColumn)
(column) => column instanceof EntityTableColumn || column instanceof EntityLinkTableColumn ||
column instanceof ClientChipsEntityTableColumn)
.map(column => column as EntityTableColumn<BaseData<HasId>>);
this.actionColumns = this.entitiesTableConfig.columns.filter(
(column) => column instanceof EntityActionTableColumn)

2
ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts

@ -228,7 +228,7 @@ export class EntityDetailsPanelComponent extends PageComponent implements AfterV
}
hideDetailsTabs(): boolean {
return this.isEditValue && this.entitiesTableConfig.hideDetailsTabsOnEdit;
return this.entitiesTableConfig.hideDetailsTabs || this.isEditValue && this.entitiesTableConfig.hideDetailsTabsOnEdit;
}
reloadEntity(): Observable<BaseData<HasId>> {

4
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -171,6 +171,7 @@ import {
import { WidgetConfigComponentsModule } from '@home/components/widget/config/widget-config-components.module';
import { BasicWidgetConfigModule } from '@home/components/widget/config/basic/basic-widget-config.module';
import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delete-timeseries-panel.component';
import { ClientChipsComponent } from '@home/pages/admin/oauth2/clients/client-chips.component';
@NgModule({
declarations:
@ -308,7 +309,8 @@ import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delet
RateLimitsComponent,
RateLimitsTextComponent,
RateLimitsDetailsDialogComponent,
SendNotificationButtonComponent
SendNotificationButtonComponent,
ClientChipsComponent
],
imports: [
CommonModule,

4
ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts

@ -19,7 +19,7 @@ import { PageComponent } from '@shared/components/page.component';
import { AppState } from '@core/core.state';
import { Store } from '@ngrx/store';
import { BadgePosition, MobileAppSettings } from '@shared/models/mobile-app.models';
import { MobileAppService } from '@core/http/mobile-app.service';
import { MobileApplicationService } from '@core/http/mobile-application.service';
import { WidgetContext } from '@home/models/widget-component.models';
import { UtilsService } from '@core/services/utils.service';
import { Observable, Subject } from 'rxjs';
@ -78,7 +78,7 @@ export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnI
constructor(protected store: Store<AppState>,
protected cd: ChangeDetectorRef,
private mobileAppService: MobileAppService,
private mobileAppService: MobileApplicationService,
private utilsService: UtilsService,
private elementRef: ElementRef,
private imagePipe: ImagePipe,

14
ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts

@ -78,7 +78,7 @@ export interface HeaderActionDescriptor {
onAction: ($event: MouseEvent) => void;
}
export type EntityTableColumnType = 'content' | 'action' | 'link';
export type EntityTableColumnType = 'content' | 'action' | 'link' | 'clients';
export class BaseEntityTableColumn<T extends BaseData<HasId>> {
constructor(public type: EntityTableColumnType,
@ -141,7 +141,15 @@ export class DateEntityTableColumn<T extends BaseData<HasId>> extends EntityTabl
}
}
export type EntityColumn<T extends BaseData<HasId>> = EntityTableColumn<T> | EntityActionTableColumn<T> | EntityLinkTableColumn<T>;
export class ClientChipsEntityTableColumn<T extends BaseData<HasId>> extends BaseEntityTableColumn<T> {
constructor(public key: string,
public title: string,
public width: string = '0px') {
super('clients', key, title, width, false);
}
}
export type EntityColumn<T extends BaseData<HasId>> = EntityTableColumn<T> | EntityActionTableColumn<T> | EntityLinkTableColumn<T> | ClientChipsEntityTableColumn<T>;
export class EntityTableConfig<T extends BaseData<HasId>, P extends PageLink = PageLink, L extends BaseData<HasId> = T> {
@ -159,11 +167,13 @@ export class EntityTableConfig<T extends BaseData<HasId>, P extends PageLink = P
defaultTimewindowInterval = historyInterval(DAY);
entityType: EntityType = null;
tableTitle = '';
hideTableTitle = false;
selectionEnabled = true;
searchEnabled = true;
addEnabled = true;
entitiesDeleteEnabled = true;
detailsPanelEnabled = true;
hideDetailsTabs = false;
hideDetailsTabsOnEdit = true;
rowPointer = false;
actionsColumnTitle = null;

38
ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts

@ -14,17 +14,13 @@
/// limitations under the License.
///
import { Injectable, NgModule } from '@angular/core';
import { Resolve, RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { MailServerComponent } from '@modules/home/pages/admin/mail-server.component';
import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
import { Authority } from '@shared/models/authority.enum';
import { GeneralSettingsComponent } from '@modules/home/pages/admin/general-settings.component';
import { SecuritySettingsComponent } from '@modules/home/pages/admin/security-settings.component';
import { OAuth2SettingsComponent } from '@home/pages/admin/oauth2-settings.component';
import { Observable } from 'rxjs';
import { OAuth2Service } from '@core/http/oauth2.service';
import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component';
import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component';
import { EntitiesTableComponent } from '@home/components/entity/entities-table.component';
@ -41,17 +37,7 @@ import { RouterTabsComponent } from '@home/components/router-tabs.component';
import { auditLogsRoutes } from '@home/pages/audit-log/audit-log-routing.module';
import { ImageGalleryComponent } from '@shared/components/image/image-gallery.component';
import { MobileAppSettingsComponent } from '@home/pages/admin/mobile-app-settings.component';
@Injectable()
export class OAuth2LoginProcessingUrlResolver implements Resolve<string> {
constructor(private oauth2Service: OAuth2Service) {
}
resolve(): Observable<string> {
return this.oauth2Service.getLoginProcessingUrl();
}
}
import { oAuth2Routes } from '@home/pages/admin/oauth2/oauth2-routing.module';
const routes: Routes = [
{
@ -358,22 +344,7 @@ const routes: Routes = [
}
}
},
{
path: 'oauth2',
component: OAuth2SettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.oauth2.oauth2',
breadcrumb: {
label: 'admin.oauth2.oauth2',
icon: 'mdi:shield-account'
}
},
resolve: {
loginProcessingUrl: OAuth2LoginProcessingUrlResolver
}
},
...oAuth2Routes,
...auditLogsRoutes
]
}
@ -383,7 +354,6 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: [
OAuth2LoginProcessingUrlResolver,
ResourcesLibraryTableConfigResolver,
QueuesTableConfigResolver
]

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

@ -23,7 +23,6 @@ import { MailServerComponent } from '@modules/home/pages/admin/mail-server.compo
import { GeneralSettingsComponent } from '@modules/home/pages/admin/general-settings.component';
import { SecuritySettingsComponent } from '@modules/home/pages/admin/security-settings.component';
import { HomeComponentsModule } from '@modules/home/components/home-components.module';
import { OAuth2SettingsComponent } from '@modules/home/pages/admin/oauth2-settings.component';
import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component';
import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dialog.component';
import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component';
@ -35,6 +34,7 @@ import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-
import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component';
import { MobileAppSettingsComponent } from '@home/pages/admin/mobile-app-settings.component';
import { WidgetComponentsModule } from '@home/components/widget/widget-components.module';
import { OAuth2Module } from '@home/pages/admin/oauth2/oauth2.module';
@NgModule({
declarations:
@ -44,7 +44,6 @@ import { WidgetComponentsModule } from '@home/components/widget/widget-component
SmsProviderComponent,
SendTestSmsDialogComponent,
SecuritySettingsComponent,
OAuth2SettingsComponent,
HomeSettingsComponent,
ResourcesLibraryComponent,
ResourcesTableHeaderComponent,
@ -59,7 +58,8 @@ import { WidgetComponentsModule } from '@home/components/widget/widget-component
SharedModule,
HomeComponentsModule,
AdminRoutingModule,
WidgetComponentsModule
WidgetComponentsModule,
OAuth2Module
]
})
export class AdminModule { }

4
ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts

@ -21,7 +21,7 @@ import { PageComponent } from '@shared/components/page.component';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { Subject, takeUntil } from 'rxjs';
import { MobileAppService } from '@core/http/mobile-app.service';
import { MobileApplicationService } from '@core/http/mobile-application.service';
import {
BadgePosition,
badgePositionTranslationsMap,
@ -45,7 +45,7 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf
badgePositionTranslationsMap = badgePositionTranslationsMap;
constructor(protected store: Store<AppState>,
private mobileAppService: MobileAppService,
private mobileAppService: MobileApplicationService,
private fb: FormBuilder) {
super(store);
this.buildMobileAppSettingsForm();

614
ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.html

@ -1,614 +0,0 @@
<!--
Copyright © 2016-2024 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.
-->
<div>
<mat-card appearance="outlined" class="settings-card">
<mat-card-header>
<mat-card-title>
<span class="mat-headline-5" translate>admin.oauth2.oauth2</span>
</mat-card-title>
<span fxFlex></span>
<div tb-help="oauth2Settings"></div>
</mat-card-header>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<mat-card-content style="padding-top: 16px;">
<form [formGroup]="oauth2SettingsForm" (ngSubmit)="save()">
<fieldset class="tb-form-panel stroked gap-xs-12" style="margin-bottom: 16px" [disabled]="isLoading$ | async">
<div class="tb-form-row no-border no-padding column-xs">
<mat-chip-listbox formControlName="enabled">
<mat-chip-option [value]="true">{{ 'admin.oauth2.enable' | translate }}</mat-chip-option>
</mat-chip-listbox>
<mat-chip-listbox formControlName="edgeEnabled">
<mat-chip-option [value]="true">{{ 'admin.oauth2.edge-enable' | translate }}</mat-chip-option>
</mat-chip-listbox>
</div>
<section *ngIf="oauth2SettingsForm.get('enabled').value && oauth2ParamsInfos.controls.length">
<ng-container formArrayName="oauth2ParamsInfos">
<div>
<mat-accordion multi>
<ng-container *ngFor="let oauth2ParamsInfo of oauth2ParamsInfos.controls; let i = index; trackBy: trackByItem">
<mat-expansion-panel [formGroupName]="i">
<mat-expansion-panel-header>
<mat-panel-title fxLayoutAlign="start center">
{{ domainListTittle(oauth2ParamsInfo) }}
</mat-panel-title>
<mat-panel-description fxLayoutAlign="end center">
<button mat-icon-button
type="button"
(click)="deleteOAuth2ParamsInfo($event, i)"
matTooltip="{{ 'action.delete' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<mat-tab-group dynamicHeight>
<mat-tab label="{{ 'admin.oauth2.domains' | translate }}">
<ng-container formArrayName="domainInfos">
<div style="padding-top: 16px;"></div>
<section *ngFor="let domainInfo of domainInfos(oauth2ParamsInfo).controls; let n = index; trackBy: trackByItem"
class="domains-list">
<div [formGroupName]="n" fxLayout="row" fxLayoutGap="8px">
<div fxFlex fxLayout="row" fxLayout.xs="column" fxLayoutGap="8px">
<div fxLayout="column" fxFlex.sm="60" fxFlex.gt-sm="50">
<div fxLayout="row" fxLayout.xs="column" fxLayout.md="column" fxLayoutGap="8px" fxLayoutGap.md="0px">
<mat-form-field fxFlex="30" fxFlex.md fxFlex.xs class="mat-block">
<mat-label translate>admin.oauth2.protocol</mat-label>
<mat-select formControlName="scheme">
<mat-option *ngFor="let protocol of protocols" [value]="protocol">
{{ domainSchemaTranslations.get(protocol) | translate | uppercase }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.domain-name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="domainInfo.get('name').hasError('pattern')">
{{ 'admin.error-verification-url' | translate }}
</mat-error>
<mat-error *ngIf="domainInfo.get('name').hasError('maxlength')">
{{ 'admin.domain-name-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-error *ngIf="domainInfo.hasError('unique')">
{{ 'admin.domain-name-unique' | translate }}
</mat-error>
</div>
<div fxFlex fxLayout="column">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.redirect-uri-template</mat-label>
<input matInput [value]="redirectURI(domainInfo)" readonly>
<tb-copy-button
matSuffix
miniButton="false"
color="primary"
[copyText]="redirectURI(domainInfo)"
tooltipText="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"
tooltipPosition="above"
icon="mdi:clipboard-arrow-left">
</tb-copy-button>
</mat-form-field>
<mat-form-field fxFlex *ngIf="domainInfo.get('scheme').value === 'MIXED'" class="mat-block">
<input matInput [value]="redirectURIMixed(domainInfo)" readonly>
<tb-copy-button
matSuffix
miniButton="false"
color="primary"
[copyText]="redirectURIMixed(domainInfo)"
tooltipText="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"
tooltipPosition="above"
icon="mdi:clipboard-arrow-left">
</tb-copy-button>
</mat-form-field>
</div>
</div>
<div fxLayout="column" fxLayoutAlign="center start">
<button type="button" mat-icon-button color="primary"
(click)="removeDomainInfo($event, oauth2ParamsInfo, n)"
[disabled]="domainInfos(oauth2ParamsInfo).controls.length < 2"
matTooltip="{{ 'admin.oauth2.delete-domain' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
</section>
<div fxLayout="row" fxLayoutAlign="end center" style="margin-bottom: 1.25em">
<button mat-button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="addDomainInfo(oauth2ParamsInfo)"
type="button">
{{'admin.oauth2.add-domain' | translate}}
</button>
</div>
</ng-container>
</mat-tab>
<mat-tab label="{{ 'admin.oauth2.mobile-apps' | translate }}">
<ng-container formArrayName="mobileInfos">
<div style="padding-top: 16px;"></div>
<div *ngIf="mobileInfos(oauth2ParamsInfo).length === 0">
<span fxLayoutAlign="center center"
class="tb-prompt" translate>admin.oauth2.no-mobile-apps</span>
</div>
<section *ngFor="let mobileInfo of mobileInfos(oauth2ParamsInfo).controls; let n = index; trackBy: trackByItem"
class="apps-list">
<div [formGroupName]="n" fxLayout="row" fxLayoutGap="8px">
<div fxFlex fxLayout="row" fxLayout.xs="column" fxLayoutGap="8px">
<div fxFlex fxLayout="column">
<mat-form-field fxFlex class="mat-block" floatLabel="always" subscriptSizing="dynamic">
<mat-label translate>admin.oauth2.mobile-package</mat-label>
<input matInput formControlName="pkgName" placeholder="{{ 'admin.oauth2.mobile-package-placeholder' | translate }}" required>
<mat-hint translate>admin.oauth2.mobile-package-hint</mat-hint>
</mat-form-field>
<mat-error *ngIf="mobileInfo.hasError('unique')">
{{ 'admin.oauth2.mobile-package-unique' | translate }}
</mat-error>
</div>
<div fxFlex fxLayout="row">
<mat-form-field fxFlex class="mat-block" subscriptSizing="dynamic">
<mat-label translate>admin.oauth2.mobile-app-secret</mat-label>
<input matInput formControlName="appSecret" required>
<tb-copy-button
matSuffix
miniButton="false"
color="primary"
[copyText]="mobileInfo.get('appSecret').value"
tooltipText="{{ 'admin.oauth2.copy-mobile-app-secret' | translate }}"
tooltipPosition="above"
icon="mdi:clipboard-arrow-left">
</tb-copy-button>
<mat-hint translate>admin.oauth2.mobile-app-secret-hint</mat-hint>
<mat-error *ngIf="mobileInfo.get('appSecret').hasError('required')">
{{ 'admin.oauth2.mobile-app-secret-required' | translate }}
</mat-error>
<mat-error *ngIf="mobileInfo.get('appSecret').hasError('base64')">
{{ 'admin.oauth2.mobile-app-secret-min-length' | translate }}
</mat-error>
<mat-error *ngIf="mobileInfo.get('appSecret').hasError('minLength')">
{{ 'admin.oauth2.mobile-app-secret-base64' | translate }}
</mat-error>
</mat-form-field>
</div>
</div>
<div fxLayout="column" fxLayoutAlign="center start">
<button type="button" mat-icon-button color="primary"
(click)="removeMobileInfo($event, oauth2ParamsInfo, n)"
matTooltip="{{ 'admin.oauth2.delete-mobile-app' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
</section>
<div fxLayout="row" fxLayoutAlign="end center" style="margin-bottom: 1.25em">
<button mat-button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="addMobileInfo(oauth2ParamsInfo)"
type="button">
{{'admin.oauth2.add-mobile-app' | translate}}
</button>
</div>
</ng-container>
</mat-tab>
</mat-tab-group>
<div class="mat-h3" translate>admin.oauth2.providers</div>
<ng-container formArrayName="clientRegistrations">
<div class="container">
<mat-expansion-panel *ngFor="let registration of clientRegistrations(oauth2ParamsInfo).controls; let j = index; trackBy: trackByItem"
class="registration-card mat-elevation-z0">
<mat-expansion-panel-header>
<mat-panel-title fxLayoutAlign="start center">
{{ getProviderName(registration) }}
</mat-panel-title>
<mat-panel-description fxLayoutAlign="end center">
<button mat-icon-button
type="button"
[disabled]="clientRegistrations(oauth2ParamsInfo).controls.length < 2"
(click)="deleteRegistration($event, oauth2ParamsInfo, j)"
matTooltip="{{ 'admin.oauth2.delete-provider' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<section [formGroupName]="j">
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px">
<section fxFlex formGroupName="additionalInfo" fxLayout="row">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.login-provider</mat-label>
<mat-select formControlName="providerName">
<mat-option *ngFor="let provider of templateProvider" [value]="provider">
{{ provider }}
</mat-option>
</mat-select>
</mat-form-field>
<div [tb-help]="getHelpLink(registration)" *ngIf="!isCustomProvider(registration)"></div>
</section>
<mat-form-field floatLabel="always" fxFlex class="mat-block">
<mat-label translate>admin.oauth2.allowed-platforms</mat-label>
<mat-select formControlName="platforms" multiple placeholder="{{ 'admin.oauth2.all-platforms' | translate }}">
<mat-option *ngFor="let platform of platformTypes" [value]="platform">
{{ platformTypeTranslations.get(platform) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.client-id</mat-label>
<input matInput formControlName="clientId" required>
<mat-error *ngIf="registration.get('clientId').hasError('required')">
{{ 'admin.oauth2.client-id-required' | translate }}
</mat-error>
<mat-error *ngIf="registration.get('clientId').hasError('maxlength')">
{{ 'admin.oauth2.client-id-max-length' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.client-secret</mat-label>
<input matInput formControlName="clientSecret" required>
<mat-error *ngIf="registration.get('clientSecret').hasError('required')">
{{ 'admin.oauth2.client-secret-required' | translate }}
</mat-error>
<mat-error *ngIf="registration.get('clientSecret').hasError('maxlength')">
{{ 'admin.oauth2.client-secret-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-expansion-panel class="mat-elevation-z0 custom-settings"
[disabled]="isCustomProvider(registration)"
[expanded]="isCustomProvider(registration)">
<mat-expansion-panel-header [fxHide]="isCustomProvider(registration)">
<mat-panel-description fxLayoutAlign="end center">
{{ 'admin.oauth2.custom-setting' | translate }}
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<mat-tab-group dynamicHeight>
<mat-tab label="{{ 'admin.oauth2.general' | translate }}">
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px" style="margin-top: 16px;">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.access-token-uri</mat-label>
<input matInput formControlName="accessTokenUri" required>
<button mat-icon-button matSuffix
type="button"
(click)="toggleEditMode(registration, 'accessTokenUri')"
*ngIf="!isCustomProvider(registration)">
<mat-icon class="material-icons">create</mat-icon>
</button>
<mat-error *ngIf="registration.get('accessTokenUri').hasError('required')">
{{ 'admin.oauth2.access-token-uri-required' | translate }}
</mat-error>
<mat-error *ngIf="registration.get('accessTokenUri').hasError('pattern')">
{{ 'admin.oauth2.uri-pattern-error' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.authorization-uri</mat-label>
<input matInput formControlName="authorizationUri" required>
<button mat-icon-button matSuffix
type="button"
(click)="toggleEditMode(registration, 'authorizationUri')"
*ngIf="!isCustomProvider(registration)">
<mat-icon class="material-icons">create</mat-icon>
</button>
<mat-error *ngIf="registration.get('authorizationUri').hasError('required')">
{{ 'admin.oauth2.authorization-uri-required' | translate }}
</mat-error>
<mat-error *ngIf="registration.get('authorizationUri').hasError('pattern')">
{{ 'admin.oauth2.uri-pattern-error' | translate }}
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.jwk-set-uri</mat-label>
<input matInput formControlName="jwkSetUri">
<button mat-icon-button matSuffix
type="button" aria-label="Clear"
(click)="toggleEditMode(registration, 'jwkSetUri')"
*ngIf="!isCustomProvider(registration)">
<mat-icon class="material-icons">create</mat-icon>
</button>
<mat-error *ngIf="registration.get('jwkSetUri').hasError('pattern')">
{{ 'admin.oauth2.uri-pattern-error' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.user-info-uri</mat-label>
<input matInput formControlName="userInfoUri">
<button mat-icon-button matSuffix
type="button"
(click)="toggleEditMode(registration, 'userInfoUri')"
*ngIf="!isCustomProvider(registration)">
<mat-icon class="material-icons">create</mat-icon>
</button>
<mat-error *ngIf="registration.get('userInfoUri').hasError('pattern')">
{{ 'admin.oauth2.uri-pattern-error' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.client-authentication-method</mat-label>
<mat-select formControlName="clientAuthenticationMethod">
<mat-option *ngFor="let clientAuthenticationMethod of clientAuthenticationMethods"
[value]="clientAuthenticationMethod">
{{ clientAuthenticationMethod | uppercase }}
</mat-option>
</mat-select>
</mat-form-field>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px" *ngIf="isCustomProvider(registration)">
<mat-form-field fxFlex class="mat-block" floatLabel="always">
<mat-label translate>admin.oauth2.login-button-label</mat-label>
<input matInput formControlName="loginButtonLabel"
placeholder="{{ 'admin.oauth2.login-button-label-placeholder' | translate }}"
required>
<mat-error *ngIf="registration.get('loginButtonLabel').hasError('required')">
{{ 'admin.oauth2.login-button-label-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.login-button-icon</mat-label>
<input matInput formControlName="loginButtonIcon">
</mat-form-field>
</div>
<section formGroupName="mapperConfig">
<div fxLayout="column" fxLayoutGap="8px" style="margin-bottom: 8px;">
<mat-checkbox formControlName="allowUserCreation">
{{ 'admin.oauth2.allow-user-creation' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="activateUser">
{{ 'admin.oauth2.activate-user' | translate }}
</mat-checkbox>
</div>
</section>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.scope</mat-label>
<mat-chip-grid #scopeList>
<mat-chip-row *ngFor="let scope of registration.get('scope').value; let k = index; trackBy: trackByParams"
removable (removed)="removeScope(k, registration)">
{{scope}}
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip-row>
<input [matChipInputFor]="scopeList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
matChipInputAddOnBlur
(matChipInputTokenEnd)="addScope($event, registration)">
</mat-chip-grid>
<mat-error *ngIf="registration.get('scope').hasError('required')">
{{ 'admin.oauth2.scope-required' | translate }}
</mat-error>
</mat-form-field>
<tb-error style="display: block; margin-top: -24px;"
[error]="registration.get('scope').hasError('required')
? ('admin.oauth2.scope-required' | translate) : ''">
</tb-error>
</mat-tab>
<mat-tab label="{{ 'admin.oauth2.mapper' | translate }}">
<mat-form-field class="mat-block" style="margin-top: 16px;">
<mat-label translate>admin.oauth2.user-name-attribute-name</mat-label>
<input matInput formControlName="userNameAttributeName" required>
<mat-error
*ngIf="registration.get('userNameAttributeName').hasError('required')">
{{ 'admin.oauth2.user-name-attribute-name-required' | translate }}
</mat-error>
</mat-form-field>
<section formGroupName="mapperConfig">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.type</mat-label>
<mat-select formControlName="type">
<mat-option *ngFor="let mapperConfigType of mapperConfigTypes"
[value]="mapperConfigType">
{{ mapperConfigType }}
</mat-option>
</mat-select>
</mat-form-field>
<section formGroupName="basic"
*ngIf="registration.get('mapperConfig.type').value !== mapperConfigType.CUSTOM">
<mat-form-field class="mat-block"
*ngIf="registration.get('mapperConfig.type').value !== mapperConfigType.GITHUB">
<mat-label translate>admin.oauth2.email-attribute-key</mat-label>
<input matInput formControlName="emailAttributeKey" required>
<mat-error
*ngIf="registration.get('mapperConfig.basic.emailAttributeKey').hasError('required')">
{{ 'admin.oauth2.email-attribute-key-required' | translate }}
</mat-error>
<mat-error
*ngIf="registration.get('mapperConfig.basic.emailAttributeKey').hasError('maxlength')">
{{ 'admin.oauth2.email-attribute-key-max-length' | translate }}
</mat-error>
</mat-form-field>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.first-name-attribute-key</mat-label>
<input matInput formControlName="firstNameAttributeKey">
<mat-error
*ngIf="registration.get('mapperConfig.basic.firstNameAttributeKey').hasError('maxlength')">
{{ 'admin.oauth2.first-name-attribute-key-max-length' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.last-name-attribute-key</mat-label>
<input matInput formControlName="lastNameAttributeKey">
<mat-error
*ngIf="registration.get('mapperConfig.basic.lastNameAttributeKey').hasError('maxlength')">
{{ 'admin.oauth2.last-name-attribute-key-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.tenant-name-strategy</mat-label>
<mat-select formControlName="tenantNameStrategy">
<mat-option *ngFor="let tenantNameStrategy of tenantNameStrategies"
[value]="tenantNameStrategy">
{{ tenantNameStrategy }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field fxFlex class="mat-block" [fxShow]="registration.get('mapperConfig.basic.tenantNameStrategy').value === 'CUSTOM'">
<mat-label translate>admin.oauth2.tenant-name-pattern</mat-label>
<input matInput
formControlName="tenantNamePattern"
[required]="registration.get('mapperConfig.basic.tenantNameStrategy').value === 'CUSTOM'">
<mat-error
*ngIf="registration.get('mapperConfig.basic.tenantNamePattern').hasError('required')">
{{ 'admin.oauth2.tenant-name-pattern-required' | translate }}
</mat-error>
<mat-error
*ngIf="registration.get('mapperConfig.basic.tenantNamePattern').hasError('maxlength')">
{{ 'admin.oauth2.tenant-name-pattern-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.customer-name-pattern</mat-label>
<input matInput formControlName="customerNamePattern">
<mat-error
*ngIf="registration.get('mapperConfig.basic.customerNamePattern').hasError('maxlength')">
{{ 'admin.oauth2.customer-name-pattern-max-length' | translate }}
</mat-error>
</mat-form-field>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.default-dashboard-name</mat-label>
<input matInput formControlName="defaultDashboardName">
<mat-error
*ngIf="registration.get('mapperConfig.basic.defaultDashboardName').hasError('maxlength')">
{{ 'admin.oauth2.default-dashboard-name-max-length' | translate }}
</mat-error>
</mat-form-field>
<mat-checkbox fxFlex formControlName="alwaysFullScreen" class="checkbox-row">
{{ 'admin.oauth2.always-fullscreen' | translate}}
</mat-checkbox>
</div>
</section>
<section formGroupName="custom"
*ngIf="registration.get('mapperConfig.type').value === mapperConfigType.CUSTOM">
<mat-form-field class="mat-block">
<mat-label translate>admin.oauth2.url</mat-label>
<input matInput formControlName="url" required>
<mat-error
*ngIf="registration.get('mapperConfig.custom.url').hasError('required')">
{{ 'admin.oauth2.url-required' | translate }}
</mat-error>
<mat-error
*ngIf="registration.get('mapperConfig.custom.url').hasError('pattern')">
{{ 'admin.oauth2.url-pattern' | translate }}
</mat-error>
<mat-error
*ngIf="registration.get('mapperConfig.custom.url').hasError('maxlength')">
{{ 'admin.oauth2.url-max-length' | translate }}
</mat-error>
</mat-form-field>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>common.username</mat-label>
<input matInput formControlName="username" autocomplete="new-username">
<mat-error *ngIf="registration.get('mapperConfig.custom.username').hasError('maxlength')">
{{ 'admin.oauth2.username-max-length' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>common.password</mat-label>
<input matInput type="password" formControlName="password" autocomplete="new-password">
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="registration.get('mapperConfig.custom.password').hasError('maxlength')">
{{ 'admin.oauth2.password-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
</section>
</section>
</mat-tab>
</mat-tab-group>
</ng-template>
</mat-expansion-panel>
</section>
</ng-template>
</mat-expansion-panel>
</div>
</ng-container>
<div fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<button mat-button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="addRegistration(oauth2ParamsInfo)"
type="button">
{{'admin.oauth2.add-provider' | translate}}
</button>
</div>
</ng-template>
</mat-expansion-panel>
</ng-container>
</mat-accordion>
</div>
</ng-container>
</section>
</fieldset>
<div fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<button type="button" mat-raised-button color="primary"
[disabled]="isLoading$ | async"
*ngIf="oauth2SettingsForm.get('enabled').value"
(click)="addOAuth2ParamsInfo()">
<mat-icon>add</mat-icon>
<span translate>action.add</span>
</button>
<button mat-button mat-raised-button color="primary"
[disabled]="(isLoading$ | async) || oauth2SettingsForm.invalid || !oauth2SettingsForm.dirty"
type="submit">
{{'action.save' | translate}}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
</div>

78
ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.scss

@ -1,78 +0,0 @@
/**
* Copyright © 2016-2024 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{
.gap-xs-12 {
@media #{$mat-xs} {
gap: 12px;
}
}
.mdc-evolution-chip-set .mdc-evolution-chip {
margin-top: 0;
margin-bottom: 0;
}
.checkbox-row {
margin-top: 1em;
}
.registration-card{
margin-bottom: 0.5em;
border: 1px solid rgba(0, 0, 0, 0.2);
.custom-settings{
font-size: 16px;
.mat-expansion-panel-header{
padding: 0 2px;
}
}
}
.container{
margin-bottom: 16px;
.tb-highlight{
margin: 0;
}
.tb-hint{
padding-bottom: 0;
}
}
.mat-expansion-panel {
.mat-expansion-panel-header {
&.mat-expanded {
height: 48px;
}
}
}
}
:host ::ng-deep{
.registration-card{
.custom-settings{
.mat-expansion-panel-body{
padding: 0 2px 1em;
}
}
}
.domains-list, .apps-list {
margin-bottom: 1.5em;
}
}

613
ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.ts

@ -1,613 +0,0 @@
///
/// Copyright © 2016-2024 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, OnDestroy, OnInit } from '@angular/core';
import {
AbstractControl,
FormControl,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormGroup,
ValidationErrors,
Validators
} from '@angular/forms';
import {
ClientAuthenticationMethod,
DomainSchema,
domainSchemaTranslations,
MapperConfig,
MapperConfigBasic,
MapperConfigCustom,
MapperConfigType,
OAuth2ClientRegistrationTemplate,
OAuth2DomainInfo,
OAuth2Info,
OAuth2MobileInfo,
OAuth2ParamsInfo,
OAuth2RegistrationInfo,
PlatformType,
platformTypeTranslations,
TenantNameStrategy
} from '@shared/models/oauth2.models';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { PageComponent } from '@shared/components/page.component';
import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { MatChipInputEvent } from '@angular/material/chips';
import { WINDOW } from '@core/services/window.service';
import { forkJoin, Subscription } from 'rxjs';
import { DialogService } from '@core/services/dialog.service';
import { TranslateService } from '@ngx-translate/core';
import { isDefined, isDefinedAndNotNull, randomAlphanumeric } from '@core/utils';
import { OAuth2Service } from '@core/http/oauth2.service';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'tb-oauth2-settings',
templateUrl: './oauth2-settings.component.html',
styleUrls: ['./oauth2-settings.component.scss', './settings-card.scss']
})
export class OAuth2SettingsComponent extends PageComponent implements OnInit, HasConfirmForm, OnDestroy {
constructor(protected store: Store<AppState>,
private route: ActivatedRoute,
private oauth2Service: OAuth2Service,
private fb: UntypedFormBuilder,
private dialogService: DialogService,
private translate: TranslateService,
@Inject(WINDOW) private window: Window) {
super(store);
}
get oauth2ParamsInfos(): UntypedFormArray {
return this.oauth2SettingsForm.get('oauth2ParamsInfos') as UntypedFormArray;
}
private URL_REGEXP = /^[A-Za-z][A-Za-z\d.+-]*:\/*(?:\w+(?::\w+)?@)?[^\s/]+(?::\d+)?(?:\/[\w#!:.,?+=&%@\-/]*)?$/;
private DOMAIN_AND_PORT_REGEXP = /^(?:\w+(?::\w+)?@)?[^\s/]+(?::\d+)?$/;
private subscriptions: Subscription[] = [];
private templates = new Map<string, OAuth2ClientRegistrationTemplate>();
private defaultProvider = {
additionalInfo: {
providerName: 'Custom'
},
clientAuthenticationMethod: ClientAuthenticationMethod.POST,
userNameAttributeName: 'email',
mapperConfig: {
allowUserCreation: true,
activateUser: false,
type: MapperConfigType.BASIC,
basic: {
emailAttributeKey: 'email',
tenantNameStrategy: TenantNameStrategy.DOMAIN,
alwaysFullScreen: false
}
}
};
readonly separatorKeysCodes: number[] = [ENTER, COMMA];
oauth2SettingsForm: UntypedFormGroup;
oauth2Info: OAuth2Info;
clientAuthenticationMethods = Object.keys(ClientAuthenticationMethod);
mapperConfigType = MapperConfigType;
mapperConfigTypes = Object.keys(MapperConfigType);
tenantNameStrategies = Object.keys(TenantNameStrategy);
protocols = Object.values(DomainSchema);
domainSchemaTranslations = domainSchemaTranslations;
platformTypes = Object.values(PlatformType);
platformTypeTranslations = platformTypeTranslations;
templateProvider = ['Custom'];
showMainLoadingBar = false;
private loginProcessingUrl: string = this.route.snapshot.data.loginProcessingUrl;
private static validateScope(control: AbstractControl): ValidationErrors | null {
const scope: string[] = control.value;
if (!scope || !scope.length) {
return {
required: true
};
}
return null;
}
ngOnInit(): void {
this.buildOAuth2SettingsForm();
forkJoin([
this.oauth2Service.getOAuth2Template(),
this.oauth2Service.getOAuth2Settings()
]).subscribe(
([templates, oauth2Info]) => {
this.initTemplates(templates);
this.oauth2Info = oauth2Info;
this.initOAuth2Settings(this.oauth2Info);
}
);
}
ngOnDestroy() {
super.ngOnDestroy();
this.subscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
}
private initTemplates(templates: OAuth2ClientRegistrationTemplate[]): void {
templates.map(provider => {
delete provider.additionalInfo;
this.templates.set(provider.name, provider);
});
this.templateProvider.push(...Array.from(this.templates.keys()));
this.templateProvider.sort();
}
private formBasicGroup(mapperConfigBasic?: MapperConfigBasic): UntypedFormGroup {
let tenantNamePattern;
if (mapperConfigBasic?.tenantNamePattern) {
tenantNamePattern = mapperConfigBasic.tenantNamePattern;
} else {
tenantNamePattern = {value: null, disabled: true};
}
const basicGroup = this.fb.group({
emailAttributeKey: [mapperConfigBasic?.emailAttributeKey ? mapperConfigBasic.emailAttributeKey : 'email',
[Validators.required, Validators.maxLength(31)]],
firstNameAttributeKey: [mapperConfigBasic?.firstNameAttributeKey ? mapperConfigBasic.firstNameAttributeKey : '',
Validators.maxLength(31)],
lastNameAttributeKey: [mapperConfigBasic?.lastNameAttributeKey ? mapperConfigBasic.lastNameAttributeKey : '',
Validators.maxLength(31)],
tenantNameStrategy: [mapperConfigBasic?.tenantNameStrategy ? mapperConfigBasic.tenantNameStrategy : TenantNameStrategy.DOMAIN],
tenantNamePattern: [tenantNamePattern, [Validators.required, Validators.maxLength(255)]],
customerNamePattern: [mapperConfigBasic?.customerNamePattern ? mapperConfigBasic.customerNamePattern : null,
Validators.maxLength(255)],
defaultDashboardName: [mapperConfigBasic?.defaultDashboardName ? mapperConfigBasic.defaultDashboardName : null,
Validators.maxLength(255)],
alwaysFullScreen: [isDefinedAndNotNull(mapperConfigBasic?.alwaysFullScreen) ? mapperConfigBasic.alwaysFullScreen : false]
});
this.subscriptions.push(basicGroup.get('tenantNameStrategy').valueChanges.subscribe((domain) => {
if (domain === 'CUSTOM') {
basicGroup.get('tenantNamePattern').enable();
} else {
basicGroup.get('tenantNamePattern').disable();
}
}));
return basicGroup;
}
private formCustomGroup(mapperConfigCustom?: MapperConfigCustom): UntypedFormGroup {
return this.fb.group({
url: [mapperConfigCustom?.url ? mapperConfigCustom.url : null,
[Validators.required, Validators.pattern(this.URL_REGEXP), Validators.maxLength(255)]],
username: [mapperConfigCustom?.username ? mapperConfigCustom.username : null, Validators.maxLength(255)],
password: [mapperConfigCustom?.password ? mapperConfigCustom.password : null, Validators.maxLength(255)]
});
}
private buildOAuth2SettingsForm(): void {
this.oauth2SettingsForm = this.fb.group({
oauth2ParamsInfos: this.fb.array([]),
enabled: [false],
edgeEnabled: [{value: false, disabled: true}]
});
this.subscriptions.push(this.oauth2SettingsForm.get('enabled').valueChanges.subscribe(enabled => {
if (enabled) {
this.oauth2SettingsForm.get('edgeEnabled').enable();
} else {
this.oauth2SettingsForm.get('edgeEnabled').patchValue(false);
this.oauth2SettingsForm.get('edgeEnabled').disable();
}
}));
}
private initOAuth2Settings(oauth2Info: OAuth2Info): void {
if (oauth2Info) {
this.oauth2SettingsForm.patchValue({enabled: oauth2Info.enabled, edgeEnabled: oauth2Info.edgeEnabled});
oauth2Info.oauth2ParamsInfos.forEach((oauth2ParamsInfo) => {
this.oauth2ParamsInfos.push(this.buildOAuth2ParamsInfoForm(oauth2ParamsInfo));
});
}
}
private uniqueDomainValidator(control: UntypedFormGroup): { [key: string]: boolean } | null {
if (control.parent?.value) {
const domain = control.value.name;
const listProtocols = control.parent.getRawValue()
.filter((domainInfo) => domainInfo.name === domain)
.map((domainInfo) => domainInfo.scheme);
if (listProtocols.length > 1 && listProtocols.indexOf(DomainSchema.MIXED) > -1 ||
new Set(listProtocols).size !== listProtocols.length) {
return {unique: true};
}
}
return null;
}
private uniquePkgNameValidator(control: UntypedFormGroup): { [key: string]: boolean } | null {
if (control.parent?.value) {
const pkgName = control.value.pkgName;
const mobileInfosList = control.parent.getRawValue()
.filter((mobileInfo) => mobileInfo.pkgName === pkgName);
if (mobileInfosList.length > 1) {
return {unique: true};
}
}
return null;
}
public domainListTittle(control: AbstractControl): string {
const domainInfos = control.get('domainInfos').value as OAuth2DomainInfo[];
if (domainInfos.length) {
const domainList = new Set<string>();
domainInfos.forEach((domain) => {
domainList.add(domain.name);
});
return Array.from(domainList).join(', ');
}
return this.translate.instant('admin.oauth2.new-domain');
}
private buildOAuth2ParamsInfoForm(oauth2ParamsInfo?: OAuth2ParamsInfo): UntypedFormGroup {
const formOAuth2Params = this.fb.group({
domainInfos: this.fb.array([], Validators.required),
mobileInfos: this.fb.array([]),
clientRegistrations: this.fb.array([], Validators.required)
});
if (oauth2ParamsInfo) {
oauth2ParamsInfo.domainInfos.forEach((domain) => {
this.domainInfos(formOAuth2Params).push(this.buildDomainInfoForm(domain));
});
oauth2ParamsInfo.mobileInfos.forEach((mobile) => {
this.mobileInfos(formOAuth2Params).push(this.buildMobileInfoForm(mobile));
});
oauth2ParamsInfo.clientRegistrations.forEach((registration) => {
this.clientRegistrations(formOAuth2Params).push(this.buildRegistrationForm(registration));
});
} else {
this.clientRegistrations(formOAuth2Params).push(this.buildRegistrationForm());
this.domainInfos(formOAuth2Params).push(this.buildDomainInfoForm());
}
return formOAuth2Params;
}
private buildDomainInfoForm(domainInfo?: OAuth2DomainInfo): UntypedFormGroup {
return this.fb.group({
name: [domainInfo ? domainInfo.name : this.window.location.hostname, [
Validators.required, Validators.maxLength(255),
Validators.pattern(this.DOMAIN_AND_PORT_REGEXP)]],
scheme: [domainInfo?.scheme ? domainInfo.scheme : DomainSchema.HTTPS, Validators.required]
}, {validators: this.uniqueDomainValidator});
}
private buildMobileInfoForm(mobileInfo?: OAuth2MobileInfo): UntypedFormGroup {
return this.fb.group({
pkgName: [mobileInfo?.pkgName, [Validators.required]],
appSecret: [mobileInfo?.appSecret, [Validators.required, this.base64Format]],
}, {validators: this.uniquePkgNameValidator});
}
private base64Format(control: FormControl): { [key: string]: boolean } | null {
if (control.value === '') {
return null;
}
try {
const value = atob(control.value);
if (value.length < 64) {
return {minLength: true};
}
return null;
} catch (e) {
return {base64: true};
}
}
private buildRegistrationForm(registration?: OAuth2RegistrationInfo): UntypedFormGroup {
let additionalInfo = null;
if (isDefinedAndNotNull(registration?.additionalInfo)) {
additionalInfo = registration.additionalInfo;
if (this.templateProvider.indexOf(additionalInfo.providerName) === -1) {
additionalInfo.providerName = 'Custom';
}
}
let defaultProviderName = 'Custom';
if (this.templateProvider.indexOf('Google')) {
defaultProviderName = 'Google';
}
const clientRegistrationFormGroup = this.fb.group({
additionalInfo: this.fb.group({
providerName: [additionalInfo?.providerName ? additionalInfo?.providerName : defaultProviderName, Validators.required]
}),
platforms: [registration?.platforms ? registration.platforms : []],
loginButtonLabel: [registration?.loginButtonLabel ? registration.loginButtonLabel : null, Validators.required],
loginButtonIcon: [registration?.loginButtonIcon ? registration.loginButtonIcon : null],
clientId: [registration?.clientId ? registration.clientId : '', [Validators.required, Validators.maxLength(255)]],
clientSecret: [registration?.clientSecret ? registration.clientSecret : '', [Validators.required, Validators.maxLength(2048)]],
accessTokenUri: [registration?.accessTokenUri ? registration.accessTokenUri : '',
[Validators.required,
Validators.pattern(this.URL_REGEXP)]],
authorizationUri: [registration?.authorizationUri ? registration.authorizationUri : '',
[Validators.required,
Validators.pattern(this.URL_REGEXP)]],
scope: this.fb.array(registration?.scope ? registration.scope : [], OAuth2SettingsComponent.validateScope),
jwkSetUri: [registration?.jwkSetUri ? registration.jwkSetUri : '', Validators.pattern(this.URL_REGEXP)],
userInfoUri: [registration?.userInfoUri ? registration.userInfoUri : '',
[Validators.pattern(this.URL_REGEXP)]],
clientAuthenticationMethod: [
registration?.clientAuthenticationMethod ? registration.clientAuthenticationMethod : ClientAuthenticationMethod.POST,
Validators.required],
userNameAttributeName: [
registration?.userNameAttributeName ? registration.userNameAttributeName : 'email', Validators.required],
mapperConfig: this.fb.group({
allowUserCreation: [
isDefinedAndNotNull(registration?.mapperConfig?.allowUserCreation) ?
registration.mapperConfig.allowUserCreation : true
],
activateUser: [
isDefinedAndNotNull(registration?.mapperConfig?.activateUser) ? registration.mapperConfig.activateUser : false
],
type: [
registration?.mapperConfig?.type ? registration.mapperConfig.type : MapperConfigType.BASIC, Validators.required
]
}
)
});
if (registration) {
this.changeMapperConfigType(clientRegistrationFormGroup, registration.mapperConfig.type, registration.mapperConfig);
} else {
this.changeMapperConfigType(clientRegistrationFormGroup, MapperConfigType.BASIC);
this.setProviderDefaultValue(defaultProviderName, clientRegistrationFormGroup);
}
this.subscriptions.push(clientRegistrationFormGroup.get('mapperConfig.type').valueChanges.subscribe((value) => {
this.changeMapperConfigType(clientRegistrationFormGroup, value);
}));
this.subscriptions.push(clientRegistrationFormGroup.get('additionalInfo.providerName').valueChanges.subscribe((provider) => {
(clientRegistrationFormGroup.get('scope') as UntypedFormArray).clear();
this.setProviderDefaultValue(provider, clientRegistrationFormGroup);
}));
return clientRegistrationFormGroup;
}
private setProviderDefaultValue(provider: string, clientRegistration: UntypedFormGroup) {
if (provider === 'Custom') {
clientRegistration.reset(this.defaultProvider, {emitEvent: false});
clientRegistration.get('accessTokenUri').enable();
clientRegistration.get('authorizationUri').enable();
clientRegistration.get('jwkSetUri').enable();
clientRegistration.get('userInfoUri').enable();
} else {
const template = this.templates.get(provider);
template.clientId = '';
template.clientSecret = '';
template.scope.forEach(() => {
(clientRegistration.get('scope') as UntypedFormArray).push(this.fb.control(''));
});
clientRegistration.get('accessTokenUri').disable();
clientRegistration.get('authorizationUri').disable();
clientRegistration.get('jwkSetUri').disable();
clientRegistration.get('userInfoUri').disable();
clientRegistration.patchValue(template);
}
}
private changeMapperConfigType(control: AbstractControl, type: MapperConfigType, predefinedValue?: MapperConfig) {
const mapperConfig = control.get('mapperConfig') as UntypedFormGroup;
if (type === MapperConfigType.CUSTOM) {
mapperConfig.removeControl('basic');
mapperConfig.addControl('custom', this.formCustomGroup(predefinedValue?.custom));
} else {
mapperConfig.removeControl('custom');
if (!mapperConfig.get('basic')) {
mapperConfig.addControl('basic', this.formBasicGroup(predefinedValue?.basic));
}
if (type === MapperConfigType.GITHUB) {
mapperConfig.get('basic.emailAttributeKey').disable();
mapperConfig.get('basic.emailAttributeKey').patchValue(null, {emitEvent: false});
} else {
mapperConfig.get('basic.emailAttributeKey').enable();
}
}
}
save(): void {
const setting = this.oauth2SettingsForm.getRawValue();
this.oauth2Service.saveOAuth2Settings(setting).subscribe(
(oauth2Settings) => {
this.oauth2Info = oauth2Settings;
this.oauth2SettingsForm.patchValue(this.oauth2SettingsForm, {emitEvent: false});
this.oauth2SettingsForm.markAsUntouched();
this.oauth2SettingsForm.markAsPristine();
}
);
}
confirmForm(): UntypedFormGroup {
return this.oauth2SettingsForm;
}
addScope(event: MatChipInputEvent, control: AbstractControl): void {
const input = event.chipInput.inputElement;
const value = event.value;
const controller = control.get('scope') as UntypedFormArray;
if ((value.trim() !== '')) {
controller.push(this.fb.control(value.trim()));
controller.markAsDirty();
}
if (input) {
input.value = '';
}
}
removeScope(i: number, control: AbstractControl): void {
const controller = control.get('scope') as UntypedFormArray;
controller.removeAt(i);
controller.markAsTouched();
controller.markAsDirty();
}
addOAuth2ParamsInfo(): void {
this.oauth2ParamsInfos.push(this.buildOAuth2ParamsInfoForm());
}
deleteOAuth2ParamsInfo($event: Event, index: number): void {
if ($event) {
$event.stopPropagation();
$event.preventDefault();
}
const domainName = this.domainListTittle(this.oauth2ParamsInfos.at(index));
this.dialogService.confirm(
this.translate.instant('admin.oauth2.delete-domain-title', {domainName: domainName || ''}),
this.translate.instant('admin.oauth2.delete-domain-text'), null,
this.translate.instant('action.delete')
).subscribe((data) => {
if (data) {
this.oauth2ParamsInfos.removeAt(index);
this.oauth2ParamsInfos.markAsTouched();
this.oauth2ParamsInfos.markAsDirty();
}
});
}
clientRegistrations(control: AbstractControl): UntypedFormArray {
return control.get('clientRegistrations') as UntypedFormArray;
}
domainInfos(control: AbstractControl): UntypedFormArray {
return control.get('domainInfos') as UntypedFormArray;
}
mobileInfos(control: AbstractControl): UntypedFormArray {
return control.get('mobileInfos') as UntypedFormArray;
}
addRegistration(control: AbstractControl): void {
this.clientRegistrations(control).push(this.buildRegistrationForm());
}
deleteRegistration($event: Event, control: AbstractControl, index: number): void {
if ($event) {
$event.stopPropagation();
$event.preventDefault();
}
const providerName = this.clientRegistrations(control).at(index).get('additionalInfo.providerName').value;
this.dialogService.confirm(
this.translate.instant('admin.oauth2.delete-registration-title', {name: providerName || ''}),
this.translate.instant('admin.oauth2.delete-registration-text'), null,
this.translate.instant('action.delete')
).subscribe((data) => {
if (data) {
this.clientRegistrations(control).removeAt(index);
this.clientRegistrations(control).markAsTouched();
this.clientRegistrations(control).markAsDirty();
}
});
}
toggleEditMode(control: AbstractControl, path: string) {
control.get(path).disabled ? control.get(path).enable() : control.get(path).disable();
}
getProviderName(controller: AbstractControl): string {
return controller.get('additionalInfo.providerName').value;
}
isCustomProvider(controller: AbstractControl): boolean {
return this.getProviderName(controller) === 'Custom';
}
getHelpLink(controller: AbstractControl): string {
const provider = controller.get('additionalInfo.providerName').value;
if (provider === null || provider === 'Custom') {
return '';
}
return this.templates.get(provider).helpLink;
}
addDomainInfo(control: AbstractControl): void {
this.domainInfos(control).push(this.buildDomainInfoForm({
name: '',
scheme: DomainSchema.HTTPS
}));
}
removeDomainInfo($event: Event, control: AbstractControl, index: number): void {
if ($event) {
$event.stopPropagation();
$event.preventDefault();
}
this.domainInfos(control).removeAt(index);
this.domainInfos(control).markAsTouched();
this.domainInfos(control).markAsDirty();
}
addMobileInfo(control: AbstractControl): void {
this.mobileInfos(control).push(this.buildMobileInfoForm({
pkgName: '',
appSecret: btoa(randomAlphanumeric(64))
}));
}
removeMobileInfo($event: Event, control: AbstractControl, index: number): void {
if ($event) {
$event.stopPropagation();
$event.preventDefault();
}
this.mobileInfos(control).removeAt(index);
this.mobileInfos(control).markAsTouched();
this.mobileInfos(control).markAsDirty();
}
redirectURI(control: AbstractControl, schema?: DomainSchema): string {
const domainInfo = control.value as OAuth2DomainInfo;
if (domainInfo.name !== '') {
let protocol;
if (isDefined(schema)) {
protocol = schema.toLowerCase();
} else {
protocol = domainInfo.scheme === DomainSchema.MIXED ? DomainSchema.HTTPS.toLowerCase() : domainInfo.scheme.toLowerCase();
}
return `${protocol}://${domainInfo.name}${this.loginProcessingUrl}`;
}
return '';
}
redirectURIMixed(control: AbstractControl): string {
return this.redirectURI(control, DomainSchema.HTTP);
}
trackByParams(index: number): number {
return index;
}
trackByItem(i, item) {
return item;
}
}

22
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-chips.component.html

@ -0,0 +1,22 @@
<!--
Copyright © 2016-2024 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.
-->
<a *ngFor="let client of clientsEntity?.oauth2ClientInfos" class="tb-client-chip"
routerLink="{{clientPrefixUrl}}/{{client.id.id}}" (click)="$event.stopPropagation();">
{{ client.title }}
</a>

34
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-chips.component.scss

@ -0,0 +1,34 @@
/**
* Copyright © 2016-2024 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 {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 4px 0;
a.tb-client-chip {
padding: 4px 8px;
border-radius: 12px;
line-height: 16px;
background: #e0e0e0;
color: #212121;
white-space: nowrap;
border: none;
&:hover {
background: #b7b7b7;
}
}
}

40
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-chips.component.ts

@ -0,0 +1,40 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, Input } from '@angular/core';
import { HasOauth2Clients } from '@shared/models/oauth2.models';
@Component({
selector: 'tb-client-chips',
templateUrl: './client-chips.component.html',
styleUrls: ['./client-chips.component.scss']
})
export class ClientChipsComponent {
clientsEntityValue?: HasOauth2Clients;
clientPrefixUrl = '/security-settings/oauth2/clients/details/';
@Input()
set clientsEntity(value: HasOauth2Clients) {
this.clientsEntityValue = value;
}
get clientsEntity(): HasOauth2Clients {
return this.clientsEntityValue;
}
}

49
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-dialog.component.html

@ -0,0 +1,49 @@
<!--
Copyright © 2016-2024 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.
-->
<form (ngSubmit)="save()" style="width: 850px;max-height: 100vh">
<mat-toolbar color="primary">
<h2>{{ 'admin.oauth2.add-client' | translate }}</h2>
<span fxFlex></span>
<div [tb-help]="helpLinkId()"></div>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content style="padding: 0">
<tb-client #clientComponent></tb-client>
</div>
<div mat-dialog-actions fxLayoutAlign="end center">
<button mat-button color="primary"
type="button"
cdkFocusInitial
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || clientComponent.entityForm?.invalid || !clientComponent.entityForm?.dirty">
{{ 'action.add' | translate }}
</button>
</div>
</form>

80
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-dialog.component.ts

@ -0,0 +1,80 @@
///
/// Copyright © 2016-2024 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, SkipSelf, 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, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { getProviderHelpLink, OAuth2Client } from '@shared/models/oauth2.models';
import { OAuth2Service } from '@core/http/oauth2.service';
import { ClientComponent } from '@home/pages/admin/oauth2/clients/client.component';
import { ErrorStateMatcher } from '@angular/material/core';
@Component({
selector: 'tb-client-dialog',
templateUrl: './client-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: ClientDialogComponent}],
styleUrls: []
})
export class ClientDialogComponent extends DialogComponent<ClientDialogComponent, OAuth2Client> implements OnDestroy {
submitted = false;
@ViewChild('clientComponent', {static: true}) clientComponent: ClientComponent;
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<ClientDialogComponent, OAuth2Client>,
private fb: FormBuilder,
private oauth2Service: OAuth2Service,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher) {
super(store, router, dialogRef);
}
ngAfterViewInit(): void {
setTimeout(() => {
this.clientComponent.entityForm.markAsDirty();
}, 0);
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
}
cancel(): void {
this.dialogRef.close(null);
}
save() {
this.submitted = true;
if (this.clientComponent.entityForm.valid) {
this.oauth2Service.saveOAuth2Client(this.clientComponent.entityFormValue()).subscribe(
(client) => {
this.dialogRef.close(client);
}
);
}
}
helpLinkId() {
return getProviderHelpLink(this.clientComponent.entityForm.get('additionalInfo.providerName')?.value);
}
}

53
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-list.component.html

@ -0,0 +1,53 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<mat-form-field [formGroup]="entityListFormGroup" class="mat-block" [subscriptSizing]="subscriptSizing">
<mat-label translate>admin.oauth2.clients</mat-label>
<mat-chip-grid #chipList formControlName="entities">
<mat-chip-row
*ngFor="let entity of entities"
[removable]="!disabled"
(removed)="remove(entity)">
{{entity.title}}
<mat-icon matChipRemove *ngIf="!disabled">close</mat-icon>
</mat-chip-row>
<input matInput type="text" placeholder="Add client"
#entityInput
(focusin)="onFocus()"
formControlName="entity"
matAutocompleteOrigin
#origin="matAutocompleteOrigin"
[matAutocompleteConnectedTo]="origin"
[matAutocomplete]="entityAutocomplete"
[matChipInputFor]="chipList">
</mat-chip-grid>
<mat-autocomplete #entityAutocomplete="matAutocomplete"
class="tb-autocomplete"
[displayWith]="displayEntityFn">
<mat-option *ngFor="let entity of filteredEntities | async" [value]="entity">
<span [innerHTML]="entity.title | highlight:searchText"></span>
</mat-option>
<mat-option *ngIf="!(filteredEntities | async)?.length" [value]="null">
<span>
{{ translate.get('entity.no-entities-matching', {entity: searchText}) | async }}
</span>
</mat-option>
</mat-autocomplete>
<div matSuffix>
<ng-content select="[matSuffix]"></ng-content>
</div>
</mat-form-field>

182
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-list.component.ts

@ -0,0 +1,182 @@
///
/// Copyright © 2016-2024 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, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import { filter, map, mergeMap, share, tap } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { AppState } from '@app/core/core.state';
import { TranslateService } from '@ngx-translate/core';
import { BaseData } from '@shared/models/base-data';
import { EntityId } from '@shared/models/id/entity-id';
import { MatAutocomplete } from '@angular/material/autocomplete';
import { MatChipGrid } from '@angular/material/chips';
import { SubscriptSizing } from '@angular/material/form-field';
import { OAuth2Service } from '@core/http/oauth2.service';
import { OAuth2ClientInfo } from '@shared/models/oauth2.models';
import { PageLink } from '@app/shared/models/page/page-link';
@Component({
selector: 'tb-client-list',
templateUrl: './client-list.component.html',
styleUrls: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ClientListComponent),
multi: true
}
]
})
export class ClientListComponent implements ControlValueAccessor, OnInit {
entityListFormGroup: UntypedFormGroup;
modelValue: Array<OAuth2ClientInfo> | null;
@Input()
disabled: boolean;
@Input()
subscriptSizing: SubscriptSizing = 'fixed';
@ViewChild('entityInput') entityInput: ElementRef<HTMLInputElement>;
@ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete;
@ViewChild('chipList', {static: true}) chipList: MatChipGrid;
entities: Array<BaseData<EntityId>> = [];
filteredEntities: Observable<Array<BaseData<EntityId>>>;
searchText = '';
private dirty = false;
private propagateChange = (v: any) => {
};
constructor(private store: Store<AppState>,
public translate: TranslateService,
private oauth2Service: OAuth2Service,
private fb: UntypedFormBuilder) {
this.entityListFormGroup = this.fb.group({
entities: [this.entities],
entity: [null]
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
ngOnInit() {
this.filteredEntities = this.entityListFormGroup.get('entity').valueChanges
.pipe(
tap((value) => {
if (value && typeof value !== 'string') {
this.add(value);
} else if (value === null) {
this.clear(this.entityInput.nativeElement.value);
}
}),
filter((value) => typeof value === 'string'),
map((value) => value ? (typeof value === 'string' ? value : value.title) : ''),
mergeMap(name => this.fetchEntities(name)),
share()
);
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.entityListFormGroup.disable({emitEvent: false});
} else {
this.entityListFormGroup.enable({emitEvent: false});
}
}
writeValue(value: Array<OAuth2ClientInfo> | null): void {
this.searchText = '';
if (value != null && value.length > 0) {
this.modelValue = [...value];
this.entities = [...value];
} else {
this.entities = [];
this.entityListFormGroup.get('entities').setValue(this.entities);
this.modelValue = null;
}
this.dirty = true;
}
add(entity: OAuth2ClientInfo): void {
if (!this.modelValue || !this.modelValue.find(client => client.id.id === entity.id.id)) {
if (!this.modelValue) {
this.modelValue = [];
}
this.modelValue.push(entity);
this.entities.push(entity);
this.entityListFormGroup.get('entities').setValue(this.entities);
}
this.propagateChange(this.modelValue);
this.clear();
}
remove(entity: OAuth2ClientInfo) {
let index = this.entities.indexOf(entity);
if (index >= 0) {
this.entities.splice(index, 1);
this.entityListFormGroup.get('entities').setValue(this.entities);
index = this.modelValue.indexOf(entity);
this.modelValue.splice(index, 1);
if (!this.modelValue.length) {
this.modelValue = null;
}
this.propagateChange(this.modelValue);
this.clear();
}
}
displayEntityFn(entity?: OAuth2ClientInfo): string | undefined {
return entity ? entity.title : undefined;
}
fetchEntities(searchText?: string): Observable<Array<OAuth2ClientInfo>> {
this.searchText = searchText;
return this.oauth2Service.findTenantOAuth2ClientInfos(new PageLink(100, 0, searchText), {ignoreLoading: true}).pipe(
map((data) => data ? data.data : []));
}
onFocus() {
if (this.dirty) {
this.entityListFormGroup.get('entity').updateValueAndValidity({onlySelf: true, emitEvent: true});
this.dirty = false;
}
}
clear(value: string = '') {
this.entityInput.nativeElement.value = value;
this.entityListFormGroup.get('entity').patchValue(value, {emitEvent: true});
setTimeout(() => {
this.entityInput.nativeElement.blur();
this.entityInput.nativeElement.focus();
}, 0);
}
}

22
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-table-header.component.html

@ -0,0 +1,22 @@
<!--
Copyright © 2016-2024 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.
-->
<div fxLayout="row" fxLayoutAlign="start center" fxLayout.xs="column" fxLayoutAlign.xs="center start"
class="title-container">
<span *ngIf="entitiesTableConfig.tableTitle" class="tb-entity-table-title">{{ entitiesTableConfig.tableTitle }}</span>
<div tb-help="oauth2Settings"></div>
</div>

34
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-table-header.component.ts

@ -0,0 +1,34 @@
///
/// Copyright © 2016-2024 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 { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { OAuth2Client, OAuth2ClientInfo } from '@shared/models/oauth2.models';
import { PageLink } from '@shared/models/page/page-link';
@Component({
selector: 'tb-client-table-header',
templateUrl: './client-table-header.component.html',
styleUrls: []
})
export class ClientTableHeaderComponent extends EntityTableHeaderComponent<OAuth2Client, PageLink, OAuth2ClientInfo> {
constructor(protected store: Store<AppState>) {
super(store);
}
}

331
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html

@ -0,0 +1,331 @@
<!--
Copyright © 2016-2024 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.
-->
<section class="tb-form-panel no-gap no-border" [class.no-padding]="isAdd" [formGroup]="entityForm">
<div class="tb-form-row tb-standard-fields no-border no-padding">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.title</mat-label>
<input matInput formControlName="title">
<mat-error *ngIf="entityForm.get('title').hasError('required')">
{{ 'admin.oauth2.client-title-required' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs">
<div class="tb-flex" formGroupName="additionalInfo">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.provider</mat-label>
<mat-select formControlName="providerName">
<mat-option *ngFor="let provider of templateProvider" [value]="provider">
{{ provider }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-form-field class="flex" floatLabel="always">
<mat-label translate>admin.oauth2.allowed-platforms</mat-label>
<mat-select formControlName="platforms" multiple placeholder="{{ 'admin.oauth2.all-platforms' | translate }}">
<mat-option *ngFor="let platform of platformTypes" [value]="platform">
{{ platformTypeTranslations.get(platform) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.client-id</mat-label>
<input matInput formControlName="clientId">
<mat-error *ngIf="entityForm.get('clientId').hasError('required')">
{{ 'admin.oauth2.client-id-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('clientId').hasError('maxlength')">
{{ 'admin.oauth2.client-id-max-length' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.client-secret</mat-label>
<input matInput formControlName="clientSecret">
<mat-error *ngIf="entityForm.get('clientSecret').hasError('required')">
{{ 'admin.oauth2.client-secret-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('clientSecret').hasError('maxlength')">
{{ 'admin.oauth2.client-secret-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-expansion-panel class="configuration-panel mat-elevation-z0" [expanded]="">
<mat-expansion-panel-header>
<mat-panel-title fxLayoutAlign="start center" translate>
admin.oauth2.advanced-settings
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<section class="tb-form-panel no-border no-padding">
<div class="tb-form-row column-xs no-border no-padding space-between">
<span></span>
<tb-toggle-select appearance="fill" [(ngModel)]="generalSettingsMode" [ngModelOptions]="{ standalone: true }">
<tb-toggle-option [value]="true">{{ "admin.oauth2.general" | translate }}</tb-toggle-option>
<tb-toggle-option [value]="false">{{ 'admin.oauth2.mapper' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<section class="tb-form-panel no-gap no-border no-padding" style="padding-bottom: 16px"
*ngIf="generalSettingsMode">
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.access-token-uri</mat-label>
<input matInput formControlName="accessTokenUri">
<mat-error *ngIf="entityForm.get('accessTokenUri').hasError('required')">
{{ 'admin.oauth2.access-token-uri-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('accessTokenUri').hasError('pattern')">
{{ 'admin.oauth2.uri-pattern-error' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.authorization-uri</mat-label>
<input matInput formControlName="authorizationUri" required>
<mat-error *ngIf="entityForm.get('authorizationUri').hasError('required')">
{{ 'admin.oauth2.authorization-uri-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('authorizationUri').hasError('pattern')">
{{ 'admin.oauth2.uri-pattern-error' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.jwk-set-uri</mat-label>
<input matInput formControlName="jwkSetUri">
<mat-error *ngIf="entityForm.get('jwkSetUri').hasError('pattern')">
{{ 'admin.oauth2.uri-pattern-error' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.user-info-uri</mat-label>
<input matInput formControlName="userInfoUri">
<mat-error *ngIf="entityForm.get('userInfoUri').hasError('pattern')">
{{ 'admin.oauth2.uri-pattern-error' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.client-authentication-method</mat-label>
<mat-select formControlName="clientAuthenticationMethod">
<mat-option *ngFor="let clientAuthenticationMethod of clientAuthenticationMethods"
[value]="clientAuthenticationMethod">
{{ clientAuthenticationMethod | uppercase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs" *ngIf="isCustomProvider()">
<mat-form-field class="flex" floatLabel="always">
<mat-label translate>admin.oauth2.login-button-label</mat-label>
<input matInput formControlName="loginButtonLabel"
placeholder="{{ 'admin.oauth2.login-button-label-placeholder' | translate }}">
<mat-error *ngIf="entityForm.get('loginButtonLabel').hasError('required')">
{{ 'admin.oauth2.login-button-label-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.login-button-icon</mat-label>
<input matInput formControlName="loginButtonIcon">
</mat-form-field>
</div>
<section formGroupName="mapperConfig" style="margin-bottom: 22px">
<div class="tb-form-row no-border no-padding">
<mat-slide-toggle class="mat-slide" formControlName="allowUserCreation">
{{ 'admin.oauth2.allow-user-creation' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row no-border no-padding">
<mat-slide-toggle class="mat-slide" formControlName="activateUser">
{{ 'admin.oauth2.activate-user' | translate }}
</mat-slide-toggle>
</div>
</section>
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.scope</mat-label>
<mat-chip-grid #scopeList [disabled]="!isEdit">
<mat-chip-row *ngFor="let scope of entityForm.get('scope').value; let k = index; trackBy: trackByParams"
removable (removed)="removeScope(k, entityForm)">
{{scope}}
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip-row>
<input [matChipInputFor]="scopeList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
matChipInputAddOnBlur
(matChipInputTokenEnd)="addScope($event, entityForm)">
</mat-chip-grid>
<mat-error *ngIf="entityForm.get('scope').hasError('required')">
{{ 'admin.oauth2.scope-required' | translate }}
</mat-error>
</mat-form-field>
</div>
<tb-error style="display: block; margin-top: -24px;"
[error]="entityForm.get('scope').hasError('required') ? ('admin.oauth2.scope-required' | translate) : ''">
</tb-error>
</section>
<section class="tb-form-panel no-border no-padding no-gap" *ngIf="!generalSettingsMode">
<div class="tb-form-row tb-standard-fields no-border no-padding">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.user-name-attribute-name</mat-label>
<input matInput formControlName="userNameAttributeName">
<mat-error *ngIf="entityForm.get('userNameAttributeName').hasError('required')">
{{ 'admin.oauth2.user-name-attribute-name-required' | translate }}
</mat-error>
</mat-form-field>
</div>
<section class="tb-form-panel no-border no-padding no-gap" formGroupName="mapperConfig">
<div class="tb-form-row tb-standard-fields no-border no-padding">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.type</mat-label>
<mat-select formControlName="type">
<mat-option *ngFor="let mapperType of mapperTypes"
[value]="mapperType">
{{ mapperType }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<section class="tb-form-panel no-border no-padding no-gap" formGroupName="basic"
*ngIf="entityForm.get('mapperConfig.type').value !== mapperType.CUSTOM && entityForm.get('mapperConfig.basic')">
<div class="tb-form-row tb-standard-fields no-border no-padding"
*ngIf="entityForm.get('mapperConfig.type').value !== mapperType.GITHUB">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.email-attribute-key</mat-label>
<input matInput formControlName="emailAttributeKey">
<mat-error
*ngIf="entityForm.get('mapperConfig.basic.emailAttributeKey').hasError('required')">
{{ 'admin.oauth2.email-attribute-key-required' | translate }}
</mat-error>
<mat-error
*ngIf="entityForm.get('mapperConfig.basic.emailAttributeKey').hasError('maxlength')">
{{ 'admin.oauth2.email-attribute-key-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.first-name-attribute-key</mat-label>
<input matInput formControlName="firstNameAttributeKey">
<mat-error *ngIf="entityForm.get('mapperConfig.basic.firstNameAttributeKey').hasError('maxlength')">
{{ 'admin.oauth2.first-name-attribute-key-max-length' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.last-name-attribute-key</mat-label>
<input matInput formControlName="lastNameAttributeKey">
<mat-error *ngIf="entityForm.get('mapperConfig.basic.lastNameAttributeKey').hasError('maxlength')">
{{ 'admin.oauth2.last-name-attribute-key-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.first-name-attribute-key</mat-label>
<mat-select formControlName="tenantNameStrategy">
<mat-option *ngFor="let tenantNameStrategy of tenantNameStrategies"
[value]="tenantNameStrategy">
{{ tenantNameStrategy }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="flex"
[fxShow]="entityForm.get('mapperConfig.basic.tenantNameStrategy').value === 'CUSTOM'">
<mat-label translate>admin.oauth2.tenant-name-pattern</mat-label>
<input matInput formControlName="tenantNamePattern"
[required]="entityForm.get('mapperConfig.basic.tenantNameStrategy').value === 'CUSTOM'">
<mat-error
*ngIf="entityForm.get('mapperConfig.basic.tenantNamePattern').hasError('required')">
{{ 'admin.oauth2.tenant-name-pattern-required' | translate }}
</mat-error>
<mat-error
*ngIf="entityForm.get('mapperConfig.basic.tenantNamePattern').hasError('maxlength')">
{{ 'admin.oauth2.tenant-name-pattern-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.customer-name-pattern</mat-label>
<input matInput formControlName="customerNamePattern">
<mat-error *ngIf="entityForm.get('mapperConfig.basic.customerNamePattern').hasError('maxlength')">
{{ 'admin.oauth2.customer-name-pattern-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.default-dashboard-name</mat-label>
<input matInput formControlName="defaultDashboardName">
<mat-error *ngIf="entityForm.get('mapperConfig.basic.defaultDashboardName').hasError('maxlength')">
{{ 'admin.oauth2.default-dashboard-name-max-length' | translate }}
</mat-error>
</mat-form-field>
<mat-slide-toggle class="tb-flex mat-slide" style="margin-bottom: 16px"
formControlName="alwaysFullScreen">
{{ 'admin.oauth2.always-fullscreen' | translate}}
</mat-slide-toggle>
</div>
</section>
<section class="tb-form-panel no-border no-padding no-gap" formGroupName="custom"
*ngIf="entityForm.get('mapperConfig.type').value === mapperType.CUSTOM && entityForm.get('mapperConfig.custom')">
<div class="tb-form-row tb-standard-fields no-border no-padding">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.url</mat-label>
<input matInput formControlName="url">
<mat-error *ngIf="entityForm.get('mapperConfig.custom.url').hasError('required')">
{{ 'admin.oauth2.url-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('mapperConfig.custom.url').hasError('pattern')">
{{ 'admin.oauth2.url-pattern' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('mapperConfig.custom.url').hasError('maxlength')">
{{ 'admin.oauth2.url-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs">
<mat-form-field class="flex">
<mat-label translate>common.username</mat-label>
<input matInput formControlName="username" autocomplete="new-username">
<mat-error *ngIf="entityForm.get('mapperConfig.custom.username').hasError('maxlength')">
{{ 'admin.oauth2.username-max-length' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="flex">
<mat-label translate>common.password</mat-label>
<input matInput formControlName="password" autocomplete="new-password">
<mat-error *ngIf="entityForm.get('mapperConfig.custom.password').hasError('maxlength')">
{{ 'admin.oauth2.password-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-slide-toggle class="tb-flex mat-slide" style="margin-bottom: 16px"
formControlName="sendToken">
{{ 'admin.oauth2.send-token' | translate}}
</mat-slide-toggle>
</section>
</section>
</section>
</section>
</ng-template>
</mat-expansion-panel>
</section>

36
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.scss

@ -0,0 +1,36 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
::ng-deep {
.mat-expansion-panel {
.mat-expansion-panel-header {
height: 48px;
padding: 0 12px;
&.mat-expanded {
height: 48px;
}
}
.mat-expansion-panel-body {
padding: 0 12px;
}
&.configuration-panel {
border: 1px solid rgba(0, 0, 0, 0.2);
}
}
}

377
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.ts

@ -0,0 +1,377 @@
///
/// Copyright © 2016-2024 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 { ChangeDetectorRef, Component, Inject, Optional } from '@angular/core';
import { EntityComponent } from '@home/components/entity/entity.component';
import {
ClientAuthenticationMethod,
MapperType,
OAuth2BasicMapperConfig,
OAuth2Client,
OAuth2ClientInfo,
OAuth2ClientRegistrationTemplate,
OAuth2CustomMapperConfig,
OAuth2MapperConfig,
PlatformType,
platformTypeTranslations,
TenantNameStrategyType
} from '@shared/models/oauth2.models';
import { AppState } from '@core/core.state';
import { EntityTableConfig } from '@home/models/entity/entities-table-config.models';
import { TranslateService } from '@ngx-translate/core';
import { Store } from '@ngrx/store';
import {
AbstractControl,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormGroup,
ValidationErrors,
Validators
} from '@angular/forms';
import { isDefinedAndNotNull } from '@core/utils';
import { OAuth2Service } from '@core/http/oauth2.service';
import { Subscription } from 'rxjs';
import { MatChipInputEvent } from '@angular/material/chips';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { PageLink } from '@shared/models/page/page-link';
@Component({
selector: 'tb-client',
templateUrl: './client.component.html',
styleUrls: ['./client.component.scss']
})
export class ClientComponent extends EntityComponent<OAuth2Client, PageLink, OAuth2ClientInfo> {
templateProvider = ['Custom'];
private templates = new Map<string, OAuth2ClientRegistrationTemplate>();
private defaultProvider = {
additionalInfo: {
providerName: 'Custom'
},
clientAuthenticationMethod: ClientAuthenticationMethod.POST,
userNameAttributeName: 'email',
mapperConfig: {
allowUserCreation: true,
activateUser: false,
type: MapperType.BASIC,
basic: {
emailAttributeKey: 'email',
tenantNameStrategy: TenantNameStrategyType.DOMAIN,
alwaysFullScreen: false
}
}
};
private URL_REGEXP = /^[A-Za-z][A-Za-z\d.+-]*:\/*(?:\w+(?::\w+)?@)?[^\s/]+(?::\d+)?(?:\/[\w#!:.,?+=&%@\-/]*)?$/;
private subscriptions: Array<Subscription> = [];
public static validateScope(control: AbstractControl): ValidationErrors | null {
const scope: string[] = control.value;
if (!scope || !scope.length) {
return {
required: true
};
}
return null;
}
readonly separatorKeysCodes: number[] = [ENTER, COMMA];
clientAuthenticationMethods = Object.keys(ClientAuthenticationMethod);
mapperType = MapperType;
mapperTypes = Object.keys(MapperType);
tenantNameStrategies = Object.keys(TenantNameStrategyType);
platformTypes = Object.values(PlatformType);
platformTypeTranslations = platformTypeTranslations;
generalSettingsMode = true;
readonly isDialog = false;
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
private oauth2Service: OAuth2Service,
@Optional() @Inject('entity') protected entityValue: OAuth2Client,
@Optional() @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<OAuth2Client, PageLink, OAuth2ClientInfo>,
protected cd: ChangeDetectorRef,
public fb: UntypedFormBuilder) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
this.oauth2Service.getOAuth2Template().subscribe(templates => {
this.initTemplates(templates);
});
}
ngOnDestroy() {
super.ngOnDestroy();
this.subscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
}
buildForm(entity: OAuth2Client): UntypedFormGroup {
return this.fb.group({
title: [entity?.title ? entity.title : '', [Validators.required]],
additionalInfo: this.fb.group({
providerName: [entity?.additionalInfo?.providerName ? entity?.additionalInfo?.providerName : '', Validators.required]
}),
platforms: [entity?.platforms ? entity.platforms : []],
clientId: [entity?.clientId ? entity.clientId : '', [Validators.required, Validators.maxLength(255)]],
clientSecret: [entity?.clientSecret ? entity.clientSecret : '', [Validators.required, Validators.maxLength(2048)]],
accessTokenUri: [entity?.accessTokenUri ? entity.accessTokenUri : '',
[Validators.required, Validators.pattern(this.URL_REGEXP)]],
authorizationUri: [entity?.authorizationUri ? entity.authorizationUri : '',
[Validators.required, Validators.pattern(this.URL_REGEXP)]],
jwkSetUri: [entity?.jwkSetUri ? entity.jwkSetUri : '', Validators.pattern(this.URL_REGEXP)],
userInfoUri: [entity?.userInfoUri ? entity.userInfoUri : '', [Validators.pattern(this.URL_REGEXP)]],
clientAuthenticationMethod: [entity?.clientAuthenticationMethod ?
entity.clientAuthenticationMethod : ClientAuthenticationMethod.POST, Validators.required],
loginButtonLabel: [entity?.loginButtonLabel ? entity.loginButtonLabel : null, Validators.required],
loginButtonIcon: [entity?.loginButtonIcon ? entity.loginButtonIcon : null],
userNameAttributeName: [entity?.userNameAttributeName ? entity.userNameAttributeName : 'email', Validators.required],
scope: this.fb.array(entity?.scope ? entity.scope : [], ClientComponent.validateScope),
mapperConfig: this.fb.group({
allowUserCreation: [isDefinedAndNotNull(entity?.mapperConfig?.allowUserCreation) ?
entity.mapperConfig.allowUserCreation : true],
activateUser: [isDefinedAndNotNull(entity?.mapperConfig?.activateUser) ? entity.mapperConfig.activateUser : false],
type: [entity?.mapperConfig?.type ? entity.mapperConfig.type : MapperType.BASIC, Validators.required]
})
});
}
updateForm(entity: OAuth2Client) {
this.entityForm.patchValue({
title: entity.title,
additionalInfo: {
providerName: entity.additionalInfo.providerName
},
platforms: entity.platforms,
clientId: entity.clientId,
clientSecret: entity.clientSecret,
accessTokenUri: entity.accessTokenUri,
authorizationUri: entity.authorizationUri,
jwkSetUri: entity.jwkSetUri,
userInfoUri: entity.userInfoUri,
clientAuthenticationMethod: entity.clientAuthenticationMethod,
loginButtonLabel: entity.loginButtonLabel,
loginButtonIcon: entity.loginButtonIcon,
userNameAttributeName: entity.userNameAttributeName,
mapperConfig: {
allowUserCreation: entity.mapperConfig.allowUserCreation,
activateUser: entity.mapperConfig.activateUser,
type: entity.mapperConfig.type
}
}, {emitEvent: false});
const mapperConfig = this.entityForm.get('mapperConfig') as UntypedFormGroup;
if (entity.mapperConfig.basic) {
mapperConfig.removeControl('custom', {emitEvent: false});
if (mapperConfig.get('basic')) {
this.entityForm.get('mapperConfig').patchValue({
basic: entity.mapperConfig.basic
});
} else {
mapperConfig.addControl('basic', this.formBasicGroup(entity.mapperConfig.basic));
}
} else {
mapperConfig.removeControl('basic', {emitEvent: false});
if (mapperConfig.get('custom')) {
this.entityForm.get('mapperConfig').patchValue({
custom: entity.mapperConfig.custom
});
} else {
mapperConfig.addControl('custom', this.formCustomGroup(entity.mapperConfig.custom));
}
}
const scopeControls = this.entityForm.get('scope') as UntypedFormArray;
if (entity.scope.length === scopeControls.length) {
scopeControls.patchValue(entity.scope, {emitEvent: false});
} else {
const scopeControls: Array<AbstractControl> = [];
if (entity.scope) {
for (const scope of entity.scope) {
scopeControls.push(this.fb.control(scope, [Validators.required]));
}
}
this.entityForm.setControl('scope', this.fb.array(scopeControls));
}
}
getProviderName(): string {
return this.entityForm.get('additionalInfo.providerName').value;
}
isCustomProvider(): boolean {
return this.getProviderName() === 'Custom';
}
trackByParams(index: number): number {
return index;
}
trackByItem(i, item) {
return item;
}
addScope(event: MatChipInputEvent, control: AbstractControl): void {
const input = event.chipInput.inputElement;
const value = event.value;
const controller = control.get('scope') as UntypedFormArray;
if ((value.trim() !== '')) {
controller.push(this.fb.control(value.trim()));
controller.markAsDirty();
}
if (input) {
input.value = '';
}
}
removeScope(i: number, control: AbstractControl): void {
const controller = control.get('scope') as UntypedFormArray;
controller.removeAt(i);
controller.markAsTouched();
controller.markAsDirty();
}
private initTemplates(templates: OAuth2ClientRegistrationTemplate[]): void {
templates.map(provider => {
delete provider.additionalInfo;
this.templates.set(provider.name, provider);
});
this.templateProvider.push(...Array.from(this.templates.keys()));
this.templateProvider.sort();
let additionalInfo = null;
if (isDefinedAndNotNull(this.entityValue?.additionalInfo)) {
additionalInfo = this.entityValue.additionalInfo;
if (this.templateProvider.indexOf(additionalInfo.providerName) === -1) {
additionalInfo.providerName = 'Custom';
}
}
let defaultProviderName = 'Custom';
if (this.templateProvider.indexOf('Google')) {
defaultProviderName = 'Google';
}
this.entityForm.get('additionalInfo.providerName').setValue(additionalInfo?.providerName ?
additionalInfo?.providerName : defaultProviderName);
if (this.entityValue?.mapperConfig) {
this.changeMapperConfigType(this.entityForm, this.entityValue.mapperConfig.type, this.entityValue.mapperConfig);
} else {
this.changeMapperConfigType(this.entityForm, MapperType.BASIC);
this.setProviderDefaultValue(defaultProviderName, this.entityForm);
}
this.subscriptions.push(this.entityForm.get('mapperConfig.type').valueChanges.subscribe((value) => {
this.changeMapperConfigType(this.entityForm, value);
}));
this.subscriptions.push(this.entityForm.get('additionalInfo.providerName').valueChanges.subscribe((provider) => {
(this.entityForm.get('scope') as UntypedFormArray).clear();
this.setProviderDefaultValue(provider, this.entityForm);
}));
}
private changeMapperConfigType(control: AbstractControl, type: MapperType, predefinedValue?: OAuth2MapperConfig) {
const mapperConfig = control.get('mapperConfig') as UntypedFormGroup;
if (type === MapperType.CUSTOM) {
mapperConfig.removeControl('basic');
mapperConfig.addControl('custom', this.formCustomGroup(predefinedValue?.custom));
} else {
mapperConfig.removeControl('custom');
if (!mapperConfig.get('basic')) {
mapperConfig.addControl('basic', this.formBasicGroup(predefinedValue?.basic));
}
if (type === MapperType.GITHUB) {
mapperConfig.get('basic.emailAttributeKey').disable();
mapperConfig.get('basic.emailAttributeKey').patchValue(null, {emitEvent: false});
} else {
if (this.isEdit) {
mapperConfig.get('basic.emailAttributeKey').enable();
}
}
}
}
private formCustomGroup(mapperConfigCustom?: OAuth2CustomMapperConfig): UntypedFormGroup {
const customGroup = this.fb.group({
url: [mapperConfigCustom?.url ? mapperConfigCustom.url : null,
[Validators.required, Validators.pattern(this.URL_REGEXP), Validators.maxLength(255)]],
username: [mapperConfigCustom?.username ? mapperConfigCustom.username : null, Validators.maxLength(255)],
password: [mapperConfigCustom?.password ? mapperConfigCustom.password : null, Validators.maxLength(255)],
sendToken: [isDefinedAndNotNull(mapperConfigCustom?.sendToken) ? mapperConfigCustom.sendToken : false]
});
if (!this.isEdit) {
customGroup.disable();
}
return customGroup;
}
private formBasicGroup(mapperConfigBasic?: OAuth2BasicMapperConfig): UntypedFormGroup {
let tenantNamePattern;
if (mapperConfigBasic?.tenantNamePattern) {
tenantNamePattern = mapperConfigBasic.tenantNamePattern;
} else {
tenantNamePattern = {value: null, disabled: true};
}
const basicGroup = this.fb.group({
emailAttributeKey: [mapperConfigBasic?.emailAttributeKey ? mapperConfigBasic.emailAttributeKey : 'email',
[Validators.required, Validators.maxLength(31)]],
firstNameAttributeKey: [mapperConfigBasic?.firstNameAttributeKey ? mapperConfigBasic.firstNameAttributeKey : '',
Validators.maxLength(31)],
lastNameAttributeKey: [mapperConfigBasic?.lastNameAttributeKey ? mapperConfigBasic.lastNameAttributeKey : '',
Validators.maxLength(31)],
tenantNameStrategy: [mapperConfigBasic?.tenantNameStrategy ? mapperConfigBasic.tenantNameStrategy : TenantNameStrategyType.DOMAIN],
tenantNamePattern: [tenantNamePattern, [Validators.required, Validators.maxLength(255)]],
customerNamePattern: [mapperConfigBasic?.customerNamePattern ? mapperConfigBasic.customerNamePattern : null,
Validators.maxLength(255)],
defaultDashboardName: [mapperConfigBasic?.defaultDashboardName ? mapperConfigBasic.defaultDashboardName : null,
Validators.maxLength(255)],
alwaysFullScreen: [isDefinedAndNotNull(mapperConfigBasic?.alwaysFullScreen) ? mapperConfigBasic.alwaysFullScreen : false]
});
if (!this.isEdit) {
basicGroup.disable();
}
this.subscriptions.push(basicGroup.get('tenantNameStrategy').valueChanges.subscribe((domain) => {
if (domain === 'CUSTOM') {
basicGroup.get('tenantNamePattern').enable();
} else {
basicGroup.get('tenantNamePattern').disable();
}
}));
return basicGroup;
}
private setProviderDefaultValue(provider: string, clientRegistration: UntypedFormGroup) {
if (provider === 'Custom') {
clientRegistration.reset(this.defaultProvider, {emitEvent: false});
} else {
const template = this.templates.get(provider);
template.clientId = '';
template.clientSecret = '';
template.scope.forEach(() => {
(clientRegistration.get('scope') as UntypedFormArray).push(this.fb.control(''));
});
clientRegistration.patchValue(template);
}
}
}

94
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/clients-table-config.resolver.ts

@ -0,0 +1,94 @@
///
/// Copyright © 2016-2024 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 } from '@angular/core';
import { Resolve } from '@angular/router';
import {
DateEntityTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import {
getProviderHelpLink,
OAuth2Client,
OAuth2ClientInfo,
platformTypeTranslations
} from '@shared/models/oauth2.models';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { DatePipe } from '@angular/common';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { OAuth2Service } from '@core/http/oauth2.service';
import { ClientComponent } from '@home/pages/admin/oauth2/clients/client.component';
import { ClientTableHeaderComponent } from '@home/pages/admin/oauth2/clients/client-table-header.component';
import { Direction } from '@shared/models/page/sort-order';
import { PageLink } from '@shared/models/page/page-link';
@Injectable()
export class ClientsTableConfigResolver implements Resolve<EntityTableConfig<OAuth2Client, PageLink, OAuth2ClientInfo>> {
private readonly config: EntityTableConfig<OAuth2Client, PageLink, OAuth2ClientInfo> = new EntityTableConfig<OAuth2Client, PageLink, OAuth2ClientInfo>();
constructor(private translate: TranslateService,
private datePipe: DatePipe,
private utilsService: UtilsService,
private oauth2Service: OAuth2Service) {
this.config.tableTitle = this.translate.instant('admin.oauth2.clients');
this.config.hideTableTitle = true;
this.config.selectionEnabled = false;
this.config.entityType = EntityType.OAUTH2_CLIENT;
this.config.rowPointer = true;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.OAUTH2_CLIENT);
this.config.entityResources = {
helpLinkId: null,
helpLinkIdForEntity(entity: OAuth2Client): string {
return getProviderHelpLink(entity.additionalInfo.providerName);
}
};
this.config.entityComponent = ClientComponent;
this.config.headerComponent = ClientTableHeaderComponent;
this.config.hideDetailsTabs = true;
this.config.addDialogStyle = {width: '850px', maxHeight: '100vh'};
this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.config.displayPagination = false;
this.config.pageMode = false;
this.config.columns.push(
new DateEntityTableColumn<OAuth2ClientInfo>('createdTime', 'common.created-time', this.datePipe, '170px'),
new EntityTableColumn<OAuth2ClientInfo>('title', 'admin.oauth2.provider', '170px'),
new EntityTableColumn<OAuth2ClientInfo>('platforms', 'admin.oauth2.allowed-platforms', '100%',
(clientInfo) => {
return clientInfo.platforms && clientInfo.platforms.length ?
clientInfo.platforms.map(platform => this.translate.instant(platformTypeTranslations.get(platform))).join(', ') :
this.translate.instant('admin.oauth2.all-platforms');
}, () => ({}), false)
);
this.config.deleteEntityTitle = (client) => this.translate.instant('admin.oauth2.delete-client-title', {clientName: client.title});
this.config.deleteEntityContent = () => this.translate.instant('admin.oauth2.delete-client-text');
this.config.entitiesFetchFunction = pageLink => this.oauth2Service.findTenantOAuth2ClientInfos(pageLink);
this.config.loadEntity = id => this.oauth2Service.getOAuth2ClientById(id.id);
this.config.saveEntity = client => {
return this.oauth2Service.saveOAuth2Client(client as OAuth2Client);
}
this.config.deleteEntity = id => this.oauth2Service.deleteOauth2Client(id.id);
}
resolve(): EntityTableConfig<OAuth2Client, PageLink, OAuth2ClientInfo> {
return this.config;
}
}

142
ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts

@ -0,0 +1,142 @@
///
/// Copyright © 2016-2024 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 } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve } from '@angular/router';
import {
ClientChipsEntityTableColumn,
DateEntityTableColumn,
EntityActionTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { Domain, DomainInfo } from '@shared/models/oauth2.models';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { DatePipe } from '@angular/common';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { DomainService } from '@app/core/http/domain.service';
import { DomainComponent } from '@home/pages/admin/oauth2/domains/domain.component';
import { isEqual } from '@core/utils';
import { DomainTableHeaderComponent } from '@home/pages/admin/oauth2/domains/domain-table-header.component';
import { Direction } from '@app/shared/models/page/sort-order';
@Injectable()
export class DomainTableConfigResolver implements Resolve<EntityTableConfig<DomainInfo>> {
private readonly config: EntityTableConfig<DomainInfo> = new EntityTableConfig<DomainInfo>();
constructor(private translate: TranslateService,
private datePipe: DatePipe,
private utilsService: UtilsService,
private domainService: DomainService) {
this.config.tableTitle = this.translate.instant('admin.oauth2.domains');
this.config.hideTableTitle = true;
this.config.selectionEnabled = false;
this.config.entityType = EntityType.DOMAIN;
this.config.rowPointer = true;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.DOMAIN);
this.config.entityResources = entityTypeResources.get(EntityType.DOMAIN);
this.config.entityComponent = DomainComponent;
this.config.headerComponent = DomainTableHeaderComponent;
this.config.hideDetailsTabs = true;
this.config.addDialogStyle = {width: '850px', height: '688px'};
this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.config.displayPagination = false;
this.config.pageMode = false;
this.config.columns.push(
new DateEntityTableColumn<DomainInfo>('createdTime', 'common.created-time', this.datePipe, '170px'),
new EntityTableColumn<DomainInfo>('name', 'admin.oauth2.domain-name', '170px'),
new ClientChipsEntityTableColumn<DomainInfo>('oauth2ClientInfos', 'admin.oauth2.clients', '40%'),
new EntityActionTableColumn('oauth2Enabled', 'admin.oauth2.enable',
{
name: '',
nameFunction: (domain) =>
this.translate.instant(domain.oauth2Enabled ? 'admin.oauth2.disable' : 'admin.oauth2.enable'),
icon: 'mdi:toggle-switch',
iconFunction: (entity) => entity.oauth2Enabled ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline',
isEnabled: () => true,
onAction: ($event, entity) => this.toggleEnableOAuth($event, entity)
}),
new EntityActionTableColumn('propagateToEdge', 'admin.oauth2.edge',
{
name: '',
nameFunction: (domain) =>
this.translate.instant(domain.propagateToEdge ? 'admin.oauth2.edge-disable' : 'admin.oauth2.edge-enable'),
icon: 'mdi:toggle-switch',
iconFunction: (entity) => entity.propagateToEdge ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline',
isEnabled: () => true,
onAction: ($event, entity) => this.togglePropagateToEdge($event, entity)
})
);
this.config.deleteEntityTitle = (domain) => this.translate.instant('admin.oauth2.delete-domain-title', {domainName: domain.name});
this.config.deleteEntityContent = () => this.translate.instant('admin.oauth2.delete-domain-text');
this.config.entitiesFetchFunction = pageLink => this.domainService.getTenantDomainInfos(pageLink);
this.config.loadEntity = id => this.domainService.getDomainInfoById(id.id);
this.config.saveEntity = (domain, originalEntity) => {
const clientsIds = domain.oauth2ClientInfos ? domain.oauth2ClientInfos.map(clientInfo => clientInfo.id.id) : [];
if (domain.id && !isEqual(domain.oauth2ClientInfos, originalEntity.oauth2ClientInfos)) {
this.domainService.updateOauth2Clients(domain.id.id, clientsIds).subscribe();
}
delete domain.oauth2ClientInfos;
return this.domainService.saveDomain(domain as Domain, domain.id ? [] : clientsIds);
}
this.config.deleteEntity = id => this.domainService.deleteDomain(id.id);
}
resolve(route: ActivatedRouteSnapshot): EntityTableConfig<DomainInfo> {
return this.config;
}
private toggleEnableOAuth($event: Event, domain: DomainInfo): void {
if ($event) {
$event.stopPropagation();
}
const modifiedDomain: DomainInfo = {
...domain,
oauth2Enabled: !domain.oauth2Enabled
};
this.domainService.saveDomain(modifiedDomain, domain.oauth2ClientInfos.map(clientInfo => clientInfo.id.id),
{ignoreLoading: true})
.subscribe((result) => {
domain.oauth2Enabled = result.oauth2Enabled;
this.config.getTable().detectChanges();
});
}
private togglePropagateToEdge($event: Event, domain: DomainInfo): void {
if ($event) {
$event.stopPropagation();
}
const modifiedDomain: DomainInfo = {
...domain,
propagateToEdge: !domain.propagateToEdge
};
this.domainService.saveDomain(modifiedDomain, domain.oauth2ClientInfos.map(clientInfo => clientInfo.id.id),
{ignoreLoading: true})
.subscribe((result) => {
domain.propagateToEdge = result.propagateToEdge;
this.config.getTable().detectChanges();
});
}
}

21
ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-header.component.html

@ -0,0 +1,21 @@
<!--
Copyright © 2016-2024 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.
-->
<div fxLayout="row" fxLayoutAlign="start center" fxLayout.xs="column" fxLayoutAlign.xs="center start" class="title-container">
<span *ngIf="entitiesTableConfig.tableTitle" class="tb-entity-table-title">{{ entitiesTableConfig.tableTitle }}</span>
<div tb-help="oauth2Settings"></div>
</div>

33
ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-header.component.ts

@ -0,0 +1,33 @@
///
/// Copyright © 2016-2024 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 { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { DomainInfo } from '@shared/models/oauth2.models';
@Component({
selector: 'tb-domain-table-header',
templateUrl: './domain-table-header.component.html',
styleUrls: []
})
export class DomainTableHeaderComponent extends EntityTableHeaderComponent<DomainInfo> {
constructor(protected store: Store<AppState>) {
super(store);
}
}

68
ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html

@ -0,0 +1,68 @@
<!--
Copyright © 2016-2024 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.
-->
<section class="tb-form-panel no-gap no-border" [class.no-padding]="isAdd" [formGroup]="entityForm">
<div class="tb-form-row tb-standard-fields no-border no-padding">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.domain-name</mat-label>
<input matInput formControlName="name">
<mat-error *ngIf="entityForm.get('name').hasError('required')">
{{ 'admin.oauth2.domain-name-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('name').hasError('maxlength')">
{{ 'admin.domain-name-max-length' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('name').hasError('pattern')">
{{ 'admin.error-verification-url' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding">
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.redirect-url-template</mat-label>
<input matInput [value]="redirectURI()" readonly>
<tb-copy-button
matSuffix
miniButton="false"
color="primary"
[copyText]="redirectURI()"
tooltipText="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
</mat-form-field>
</div>
<div class="tb-form-row no-border no-padding">
<mat-slide-toggle class="mat-slide" formControlName="oauth2Enabled">
{{ 'admin.oauth2.enable' | translate }}
</mat-slide-toggle>
</div>
<tb-client-list formControlName="oauth2ClientInfos">
<button #createTargetButton
mat-button color="primary" matSuffix
[disabled]="!isEdit"
(click)="createClient($event, createTargetButton)">
<span style="white-space: nowrap">{{ 'notification.create-new' | translate }}</span>
</button>
</tb-client-list>
<div class="tb-form-row no-border no-padding">
<mat-slide-toggle class="mat-slide" formControlName="propagateToEdge">
{{ 'admin.oauth2.edge' | translate }}
</mat-slide-toggle>
</div>
</section>

105
ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.ts

@ -0,0 +1,105 @@
///
/// Copyright © 2016-2024 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 { ChangeDetectorRef, Component, Inject } from '@angular/core';
import { EntityComponent } from '@home/components/entity/entity.component';
import { DomainInfo } from '@shared/models/oauth2.models';
import { AppState } from '@core/core.state';
import { OAuth2Service } from '@core/http/oauth2.service';
import { EntityTableConfig } from '@home/models/entity/entities-table-config.models';
import { TranslateService } from '@ngx-translate/core';
import { Store } from '@ngrx/store';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { WINDOW } from '@core/services/window.service';
import { isDefinedAndNotNull } from '@core/utils';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { ClientDialogComponent } from '@home/pages/admin/oauth2/clients/client-dialog.component';
@Component({
selector: 'tb-domain',
templateUrl: './domain.component.html',
styleUrls: []
})
export class DomainComponent extends EntityComponent<DomainInfo> {
private loginProcessingUrl: string = '';
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
private oauth2Service: OAuth2Service,
@Inject('entity') protected entityValue: DomainInfo,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<DomainInfo>,
protected cd: ChangeDetectorRef,
public fb: UntypedFormBuilder,
@Inject(WINDOW) private window: Window,
private dialog: MatDialog) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
this.entityForm.get('name').setValue(this.window.location.hostname);
this.oauth2Service.getLoginProcessingUrl().subscribe(url => {
this.loginProcessingUrl = url;
});
}
buildForm(entity: DomainInfo): UntypedFormGroup {
return this.fb.group({
name: [entity?.name ? entity.name : '', [
Validators.required, Validators.maxLength(255), Validators.pattern('^(?:\\w+(?::\\w+)?@)?[^\\s/]+(?::\\d+)?$')]],
oauth2Enabled: isDefinedAndNotNull(entity?.oauth2Enabled) ? entity.oauth2Enabled : false,
oauth2ClientInfos: entity?.oauth2ClientInfos ? entity.oauth2ClientInfos : [],
propagateToEdge: isDefinedAndNotNull(entity?.propagateToEdge) ? entity.propagateToEdge : false
});
}
updateForm(entity: DomainInfo) {
this.entityForm.patchValue({
name: entity.name,
oauth2Enabled: entity.oauth2Enabled,
oauth2ClientInfos: entity.oauth2ClientInfos,
propagateToEdge: entity.propagateToEdge
})
}
redirectURI(): string {
const domainName = this.entityForm.get('name').value;
return domainName !== '' ? `${domainName}${this.loginProcessingUrl}` : '';
}
createClient($event: Event, button: MatButton) {
if ($event) {
$event.stopPropagation();
$event.preventDefault();
}
button._elementRef.nativeElement.blur();
this.dialog.open<ClientDialogComponent>(ClientDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {}
}).afterClosed()
.subscribe((res) => {
if (res) {
const formValue = this.entityForm.get('oauth2ClientInfos').value ?
[...this.entityForm.get('oauth2ClientInfos').value] : [];
formValue.push({id: res.id, title: res.title});
this.entityForm.get('oauth2ClientInfos').patchValue(formValue);
this.entityForm.get('oauth2ClientInfos').markAsDirty();
this.entityForm.updateValueAndValidity();
}
});
}
}

138
ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-config.resolver.ts

@ -0,0 +1,138 @@
///
/// Copyright © 2016-2024 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 } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve } from '@angular/router';
import {
CellActionDescriptorType,
ClientChipsEntityTableColumn,
DateEntityTableColumn,
EntityActionTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { MobileApp, MobileAppInfo } from '@shared/models/oauth2.models';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { DatePipe } from '@angular/common';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { isEqual } from '@core/utils';
import { Direction } from '@app/shared/models/page/sort-order';
import { MobileAppService } from '@core/http/mobile-app.service';
import { MobileAppComponent } from '@home/pages/admin/oauth2/mobile-apps/mobile-app.component';
import { MobileAppTableHeaderComponent } from '@home/pages/admin/oauth2/mobile-apps/mobile-app-table-header.component';
@Injectable()
export class MobileAppTableConfigResolver implements Resolve<EntityTableConfig<MobileAppInfo>> {
private readonly config: EntityTableConfig<MobileAppInfo> = new EntityTableConfig<MobileAppInfo>();
constructor(private translate: TranslateService,
private datePipe: DatePipe,
private utilsService: UtilsService,
private mobileAppService: MobileAppService) {
this.config.tableTitle = this.translate.instant('admin.oauth2.mobile-apps');
this.config.hideTableTitle = true;
this.config.selectionEnabled = false;
this.config.entityType = EntityType.MOBILE_APP;
this.config.rowPointer = true;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.MOBILE_APP);
this.config.entityResources = entityTypeResources.get(EntityType.MOBILE_APP);
this.config.entityComponent = MobileAppComponent;
this.config.headerComponent = MobileAppTableHeaderComponent;
this.config.hideDetailsTabs = true;
this.config.addDialogStyle = {width: '850px', height: '688px'};
this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.config.displayPagination = false;
this.config.pageMode = false;
this.config.columns.push(
new DateEntityTableColumn<MobileAppInfo>('createdTime', 'common.created-time', this.datePipe, '170px'),
new EntityTableColumn<MobileAppInfo>('pkgName', 'admin.oauth2.mobile-package', '170px'),
new EntityTableColumn<MobileAppInfo>('appSecret', 'admin.oauth2.mobile-app-secret', '350px',
(entity) => entity.appSecret ? this.appSecretText(entity) : '', () => ({}),
false, () => ({}), () => undefined, false,
{
name: this.translate.instant('admin.oauth2.copy-mobile-app-secret'),
icon: 'content_copy',
style: {
'padding-top': '1px',
'font-size': '16px',
color: 'rgba(0,0,0,.87)'
},
isEnabled: (entity) => !!entity.appSecret,
onAction: ($event, entity) => entity.appSecret,
type: CellActionDescriptorType.COPY_BUTTON
}),
new ClientChipsEntityTableColumn<MobileAppInfo>('oauth2ClientInfos', 'admin.oauth2.clients', '20%'),
new EntityActionTableColumn('oauth2Enabled', 'admin.oauth2.enable',
{
name: '',
nameFunction: (app) =>
this.translate.instant(app.oauth2Enabled ? 'admin.oauth2.disable' : 'admin.oauth2.enable'),
icon: 'mdi:toggle-switch',
iconFunction: (entity) => entity.oauth2Enabled ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline',
isEnabled: () => true,
onAction: ($event, entity) => this.toggleEnableOAuth($event, entity)
})
);
this.config.deleteEntityTitle = (app) => this.translate.instant('admin.oauth2.delete-mobile-app-title', {applicationName: app.pkgName});
this.config.deleteEntityContent = () => this.translate.instant('admin.oauth2.delete-mobile-app-text');
this.config.entitiesFetchFunction = pageLink => this.mobileAppService.getTenantMobileAppInfos(pageLink);
this.config.loadEntity = id => this.mobileAppService.getMobileAppInfoById(id.id);
this.config.saveEntity = (mobileApp, originalEntity) => {
const clientsIds = mobileApp.oauth2ClientInfos ? mobileApp.oauth2ClientInfos.map(clientInfo => clientInfo.id.id) : [];
if (mobileApp.id && !isEqual(mobileApp.oauth2ClientInfos, originalEntity.oauth2ClientInfos)) {
this.mobileAppService.updateOauth2Clients(mobileApp.id.id, clientsIds).subscribe();
}
delete mobileApp.oauth2ClientInfos;
return this.mobileAppService.saveMobileApp(mobileApp as MobileApp, mobileApp.id ? [] : clientsIds);
}
this.config.deleteEntity = id => this.mobileAppService.deleteMobileApp(id.id);
}
resolve(route: ActivatedRouteSnapshot): EntityTableConfig<MobileAppInfo> {
return this.config;
}
private toggleEnableOAuth($event: Event, mobileApp: MobileAppInfo): void {
if ($event) {
$event.stopPropagation();
}
const modifiedMobileApp: MobileAppInfo = {
...mobileApp,
oauth2Enabled: !mobileApp.oauth2Enabled
};
this.mobileAppService.saveMobileApp(modifiedMobileApp, mobileApp.oauth2ClientInfos.map(clientInfo => clientInfo.id.id),
{ignoreLoading: true})
.subscribe((result) => {
mobileApp.oauth2Enabled = result.oauth2Enabled;
this.config.getTable().detectChanges();
});
}
private appSecretText(entity): string {
let text = entity.appSecret;
if (text.length > 40) {
text = `${text.slice(0, 40)}…`;
}
return text;
}
}

21
ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-header.component.html

@ -0,0 +1,21 @@
<!--
Copyright © 2016-2024 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.
-->
<div fxLayout="row" fxLayoutAlign="start center" fxLayout.xs="column" fxLayoutAlign.xs="center start" class="title-container">
<span *ngIf="entitiesTableConfig.tableTitle" class="tb-entity-table-title">{{ entitiesTableConfig.tableTitle }}</span>
<div tb-help="oauth2Settings"></div>
</div>

33
ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-header.component.ts

@ -0,0 +1,33 @@
///
/// Copyright © 2016-2024 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 { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { MobileAppInfo } from '@shared/models/oauth2.models';
@Component({
selector: 'tb-mobile-app-table-header',
templateUrl: './mobile-app-table-header.component.html',
styleUrls: []
})
export class MobileAppTableHeaderComponent extends EntityTableHeaderComponent<MobileAppInfo> {
constructor(protected store: Store<AppState>) {
super(store);
}
}

69
ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.html

@ -0,0 +1,69 @@
<!--
Copyright © 2016-2024 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.
-->
<section class="tb-form-panel no-border" [class.no-padding]="isAdd" [formGroup]="entityForm">
<div class="tb-form-row tb-standard-fields no-border no-padding">
<mat-form-field class="flex" floatLabel="always" subscriptSizing="dynamic">
<mat-label translate>admin.oauth2.mobile-package</mat-label>
<input matInput formControlName="pkgName"
placeholder="{{ 'admin.oauth2.mobile-package-placeholder' | translate }}" required>
<mat-hint translate>admin.oauth2.mobile-package-hint</mat-hint>
<mat-error *ngIf="entityForm.hasError('unique')">
{{ 'admin.oauth2.mobile-package-unique' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding">
<mat-form-field class="flex" floatLabel="always" subscriptSizing="dynamic">
<mat-label translate>admin.oauth2.mobile-app-secret</mat-label>
<input matInput formControlName="appSecret" required>
<tb-copy-button
matSuffix
miniButton="false"
color="primary"
[copyText]="entityForm.get('appSecret').value"
tooltipText="{{ 'admin.oauth2.copy-mobile-app-secret' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
<mat-hint translate>admin.oauth2.mobile-app-secret-hint</mat-hint>
<mat-error *ngIf="entityForm.get('appSecret').hasError('required')">
{{ 'admin.oauth2.mobile-app-secret-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('appSecret').hasError('base64')">
{{ 'admin.oauth2.mobile-app-secret-min-length' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('appSecret').hasError('minLength')">
{{ 'admin.oauth2.mobile-app-secret-base64' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row no-border no-padding">
<mat-slide-toggle class="mat-slide" formControlName="oauth2Enabled">
{{ 'admin.oauth2.enable' | translate }}
</mat-slide-toggle>
</div>
<tb-client-list formControlName="oauth2ClientInfos">
<button #createTargetButton
mat-button color="primary" matSuffix
[disabled]="!isEdit"
(click)="createClient($event, createTargetButton)">
<span style="white-space: nowrap">{{ 'notification.create-new' | translate }}</span>
</button>
</tb-client-list>
</section>

108
ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.ts

@ -0,0 +1,108 @@
///
/// Copyright © 2016-2024 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 { ChangeDetectorRef, Component, Inject } from '@angular/core';
import { EntityComponent } from '@home/components/entity/entity.component';
import { MobileAppInfo } from '@shared/models/oauth2.models';
import { AppState } from '@core/core.state';
import { OAuth2Service } from '@core/http/oauth2.service';
import { EntityTableConfig } from '@home/models/entity/entities-table-config.models';
import { TranslateService } from '@ngx-translate/core';
import { Store } from '@ngrx/store';
import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
import { WINDOW } from '@core/services/window.service';
import { isDefinedAndNotNull } from '@core/utils';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { ClientDialogComponent } from '@home/pages/admin/oauth2/clients/client-dialog.component';
@Component({
selector: 'tb-mobile-app',
templateUrl: './mobile-app.component.html',
styleUrls: []
})
export class MobileAppComponent extends EntityComponent<MobileAppInfo> {
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
private oauth2Service: OAuth2Service,
@Inject('entity') protected entityValue: MobileAppInfo,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<MobileAppInfo>,
protected cd: ChangeDetectorRef,
public fb: UntypedFormBuilder,
@Inject(WINDOW) private window: Window,
private dialog: MatDialog) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
}
buildForm(entity: MobileAppInfo): UntypedFormGroup {
return this.fb.group({
pkgName: [entity?.pkgName ? entity.pkgName : '', [Validators.required]],
appSecret: [entity?.appSecret ? entity.appSecret : '', [Validators.required, this.base64Format]],
oauth2Enabled: isDefinedAndNotNull(entity?.oauth2Enabled) ? entity.oauth2Enabled : false,
oauth2ClientInfos: entity?.oauth2ClientInfos ? entity.oauth2ClientInfos : []
});
}
updateForm(entity: MobileAppInfo) {
this.entityForm.patchValue({
pkgName: entity.pkgName,
appSecret: entity.appSecret,
oauth2Enabled: entity.oauth2Enabled,
oauth2ClientInfos: entity.oauth2ClientInfos
})
}
createClient($event: Event, button: MatButton) {
if ($event) {
$event.stopPropagation();
$event.preventDefault();
}
button._elementRef.nativeElement.blur();
this.dialog.open<ClientDialogComponent>(ClientDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {}
}).afterClosed()
.subscribe((res) => {
if (res) {
const formValue = this.entityForm.get('oauth2ClientInfos').value ?
[...this.entityForm.get('oauth2ClientInfos').value] : [];
formValue.push({id: res.id, title: res.title});
this.entityForm.get('oauth2ClientInfos').patchValue(formValue);
this.entityForm.get('oauth2ClientInfos').markAsDirty();
this.entityForm.updateValueAndValidity();
}
});
}
private base64Format(control: UntypedFormControl): { [key: string]: boolean } | null {
if (control.value === '') {
return null;
}
try {
const value = atob(control.value);
if (value.length < 64) {
return {minLength: true};
}
return null;
} catch (e) {
return {base64: true};
}
}
}

152
ui-ngx/src/app/modules/home/pages/admin/oauth2/oauth2-routing.module.ts

@ -0,0 +1,152 @@
///
/// Copyright © 2016-2024 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 { RouterTabsComponent } from '@home/components/router-tabs.component';
import { Authority } from '@shared/models/authority.enum';
import { EntitiesTableComponent } from '@home/components/entity/entities-table.component';
import { OAuth2Service } from '@core/http/oauth2.service';
import { Observable } from 'rxjs';
import { ClientsTableConfigResolver } from './clients/clients-table-config.resolver';
import { DomainTableConfigResolver } from '@home/pages/admin/oauth2/domains/domain-table-config.resolver';
import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component';
import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models';
import { BreadCrumbConfig } from '@shared/components/breadcrumb';
import { MobileAppTableConfigResolver } from '@home/pages/admin/oauth2/mobile-apps/mobile-app-table-config.resolver';
@Injectable()
export class OAuth2LoginProcessingUrlResolver implements Resolve<string> {
constructor(private oauth2Service: OAuth2Service) {
}
resolve(): Observable<string> {
return this.oauth2Service.getLoginProcessingUrl();
}
}
export const oAuth2Routes: Routes = [
{
path: 'oauth2',
component: RouterTabsComponent,
data: {
auth: [Authority.SYS_ADMIN],
breadcrumb: {
label: 'admin.oauth2.oauth2',
icon: 'mdi:shield-account'
}
},
children: [
{
path: '',
children: [],
data: {
auth: [Authority.SYS_ADMIN],
redirectTo: '/security-settings/oauth2/domains'
}
},
{
path: 'domains',
component: EntitiesTableComponent,
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.oauth2.domains',
breadcrumb: {
label: 'admin.oauth2.domains',
icon: 'domain'
}
},
resolve: {
entitiesTableConfig: DomainTableConfigResolver
}
},
{
path: 'mobile-applications',
component: EntitiesTableComponent,
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.oauth2.mobile-apps',
breadcrumb: {
label: 'admin.oauth2.mobile-apps',
icon: 'smartphone'
}
},
resolve: {
entitiesTableConfig: MobileAppTableConfigResolver
}
},
{
path: 'clients',
data: {
title: 'admin.oauth2.clients',
breadcrumb: {
label: 'admin.oauth2.clients',
icon: 'public'
}
},
children: [
{
path: '',
component: EntitiesTableComponent,
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.oauth2.clients'
},
resolve: {
entitiesTableConfig: ClientsTableConfigResolver
}
},
{
path: 'details',
children: [
{
path: ':entityId',
component: EntityDetailsPageComponent,
data: {
breadcrumb: {
labelFunction: entityDetailsPageBreadcrumbLabelFunction,
icon: 'public'
} as BreadCrumbConfig<EntityDetailsPageComponent>,
auth: [Authority.SYS_ADMIN],
title: 'admin.oauth2.clients',
hideTabs: true,
backNavigationCommands: ['../..']
},
resolve: {
entitiesTableConfig: ClientsTableConfigResolver
}
}
]
}
]
}
]
}
];
@NgModule({
providers: [
OAuth2LoginProcessingUrlResolver,
ClientsTableConfigResolver,
DomainTableConfigResolver,
MobileAppTableConfigResolver
],
imports: [RouterModule.forChild(oAuth2Routes)],
exports: [RouterModule]
})
export class Oauth2RoutingModule {
}

50
ui-ngx/src/app/modules/home/pages/admin/oauth2/oauth2.module.ts

@ -0,0 +1,50 @@
///
/// Copyright © 2016-2024 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 { ClientComponent } from '@home/pages/admin/oauth2/clients/client.component';
import { Oauth2RoutingModule } from '@home/pages/admin/oauth2/oauth2-routing.module';
import { SharedModule } from '@shared/shared.module';
import { HomeComponentsModule } from '@home/components/home-components.module';
import { CommonModule } from '@angular/common';
import { ClientTableHeaderComponent } from '@home/pages/admin/oauth2/clients/client-table-header.component';
import { DomainComponent } from '@home/pages/admin/oauth2/domains/domain.component';
import { ClientListComponent } from '@home/pages/admin/oauth2/clients/client-list.component';
import { ClientDialogComponent } from '@home/pages/admin/oauth2/clients/client-dialog.component';
import { DomainTableHeaderComponent } from '@home/pages/admin/oauth2/domains/domain-table-header.component';
import { MobileAppComponent } from '@home/pages/admin/oauth2/mobile-apps/mobile-app.component';
import { MobileAppTableHeaderComponent } from '@home/pages/admin/oauth2/mobile-apps/mobile-app-table-header.component';
@NgModule({
declarations: [
ClientComponent,
ClientDialogComponent,
ClientListComponent,
ClientTableHeaderComponent,
DomainComponent,
DomainTableHeaderComponent,
MobileAppComponent,
MobileAppTableHeaderComponent
],
imports: [
Oauth2RoutingModule,
CommonModule,
SharedModule,
HomeComponentsModule
]
})
export class OAuth2Module {
}

6
ui-ngx/src/app/modules/login/pages/login/login.component.ts

@ -23,7 +23,7 @@ import { UntypedFormBuilder } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { Constants } from '@shared/models/constants';
import { Router } from '@angular/router';
import { OAuth2ClientInfo } from '@shared/models/oauth2.models';
import { OAuth2ClientLoginInfo } from '@shared/models/oauth2.models';
@Component({
selector: 'tb-login',
@ -38,7 +38,7 @@ export class LoginComponent extends PageComponent implements OnInit {
username: '',
password: ''
});
oauth2Clients: Array<OAuth2ClientInfo> = null;
oauth2Clients: Array<OAuth2ClientLoginInfo> = null;
constructor(protected store: Store<AppState>,
private authService: AuthService,
@ -73,7 +73,7 @@ export class LoginComponent extends PageComponent implements OnInit {
}
}
getOAuth2Uri(oauth2Client: OAuth2ClientInfo): string {
getOAuth2Uri(oauth2Client: OAuth2ClientLoginInfo): string {
let result = "";
if (this.authService.redirectUrl) {
result += "?prevUri=" + this.authService.redirectUrl;

4
ui-ngx/src/app/shared/models/constants.ts

@ -91,6 +91,10 @@ export const HelpLinks = {
slackSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/slack-settings`,
securitySettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/security-settings`,
oauth2Settings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/oauth-2-support/`,
oauth2Apple: 'https://developer.apple.com/sign-in-with-apple/get-started/',
oauth2Facebook: 'https://developers.facebook.com/docs/facebook-login/web#logindialog',
oauth2Github: 'https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app',
oauth2Google: 'https://developers.google.com/google-ads/api/docs/start',
ruleEngine: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/overview/`,
ruleNodeCheckRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#check-relation-filter-node`,
ruleNodeCheckExistenceFields: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#check-existence-fields-node`,

62
ui-ngx/src/app/shared/models/entity-type.models.ts

@ -44,7 +44,10 @@ export enum EntityType {
NOTIFICATION_REQUEST = 'NOTIFICATION_REQUEST',
NOTIFICATION_RULE = 'NOTIFICATION_RULE',
NOTIFICATION_TARGET = 'NOTIFICATION_TARGET',
NOTIFICATION_TEMPLATE = 'NOTIFICATION_TEMPLATE'
NOTIFICATION_TEMPLATE = 'NOTIFICATION_TEMPLATE',
OAUTH2_CLIENT = 'OAUTH2_CLIENT',
DOMAIN = 'DOMAIN',
MOBILE_APP = 'MOBILE_APP'
}
export enum AliasEntityType {
@ -423,6 +426,42 @@ export const entityTypeTranslations = new Map<EntityType | AliasEntityType, Enti
search: 'notification.search-templates',
selectedEntities: 'notification.selected-template'
}
],
[
EntityType.DOMAIN,
{
type: 'entity.type-domain',
typePlural: 'entity.type-domains',
list: 'entity.list-of-domains',
details: 'admin.oauth2.domain-details',
add: 'admin.oauth2.add-domain',
noEntities: 'admin.oauth2.no-domains',
search: 'admin.oauth2.search-domains'
}
],
[
EntityType.OAUTH2_CLIENT,
{
type: 'entity.type-oauth2-client',
typePlural: 'entity.type-oauth2-clients',
list: 'entity.list-of-oauth2-clients',
details: 'admin.oauth2.client-details',
add: 'admin.oauth2.add-client',
noEntities: 'admin.oauth2.no-oauth2-clients',
search: 'admin.oauth2.search-oauth2-clients'
}
],
[
EntityType.MOBILE_APP,
{
type: 'entity.type-mobile-app',
typePlural: 'entity.type-mobile-apps',
list: 'entity.list-of-mobile-apps',
details: 'admin.oauth2.mobile-app-details',
add: 'admin.oauth2.add-mobile-app',
noEntities: 'admin.oauth2.no-mobile-apps',
search: 'admin.oauth2.search-mobile-apps'
}
]
]
);
@ -530,6 +569,24 @@ export const entityTypeResources = new Map<EntityType, EntityTypeResource<BaseDa
{
helpLinkId: 'queue'
}
],
[
EntityType.OAUTH2_CLIENT,
{
helpLinkId: 'oauth2Settings'
}
],
[
EntityType.DOMAIN,
{
helpLinkId: 'oauth2Settings'
}
],
[
EntityType.MOBILE_APP,
{
helpLinkId: 'oauth2Settings'
}
]
]
);
@ -551,7 +608,8 @@ export const baseDetailsPageByEntityType = new Map<EntityType, string>([
[EntityType.OTA_PACKAGE, '/features/otaUpdates'],
[EntityType.QUEUE, '/settings/queues'],
[EntityType.WIDGETS_BUNDLE, '/resources/widgets-library/widgets-bundles/details'],
[EntityType.WIDGET_TYPE, '/resources/widgets-library/widget-types/details']
[EntityType.WIDGET_TYPE, '/resources/widgets-library/widget-types/details'],
[EntityType.OAUTH2_CLIENT, '/security-settings/oauth2/clients/details']
]);
export interface EntitySubtype {

27
ui-ngx/src/app/shared/models/id/domain-id.ts

@ -0,0 +1,27 @@
///
/// Copyright © 2016-2024 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 { EntityId } from '@shared/models/id/entity-id';
import { EntityType } from '@shared/models/entity-type.models';
export class DomainId implements EntityId {
entityType = EntityType.DOMAIN
id: string;
constructor(id: string) {
this.id = id;
}
}

27
ui-ngx/src/app/shared/models/id/mobile-app-id.ts

@ -0,0 +1,27 @@
///
/// Copyright © 2016-2024 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 { EntityId } from '@shared/models/id/entity-id';
import { EntityType } from '@shared/models/entity-type.models';
export class MobileAppId implements EntityId {
entityType = EntityType.MOBILE_APP
id: string;
constructor(id: string) {
this.id = id;
}
}

27
ui-ngx/src/app/shared/models/id/oauth2-client-id.ts

@ -0,0 +1,27 @@
///
/// Copyright © 2016-2024 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 { EntityId } from '@shared/models/id/entity-id';
import { EntityType } from '@shared/models/entity-type.models';
export class OAuth2ClientId implements EntityId {
entityType = EntityType.OAUTH2_CLIENT
id: string;
constructor(id: string) {
this.id = id;
}
}

176
ui-ngx/src/app/shared/models/oauth2.models.ts

@ -14,31 +14,15 @@
/// limitations under the License.
///
import { OAuth2ClientId } from '@shared/models/id/oauth2-client-id';
import { BaseData } from '@shared/models/base-data';
import { TenantId } from '@shared/models/id/tenant-id';
import { HasTenantId } from './entity.models';
import { DomainId } from './id/domain-id';
import { HasUUID } from '@shared/models/id/has-uuid';
import { MobileAppId } from '@shared/models/id/mobile-app-id';
export interface OAuth2Info {
enabled: boolean;
edgeEnabled: boolean;
oauth2ParamsInfos: OAuth2ParamsInfo[];
}
export interface OAuth2ParamsInfo {
clientRegistrations: OAuth2RegistrationInfo[];
domainInfos: OAuth2DomainInfo[];
mobileInfos: OAuth2MobileInfo[];
}
export interface OAuth2DomainInfo {
name: string;
scheme: DomainSchema;
}
export interface OAuth2MobileInfo {
pkgName: string;
appSecret: string;
}
export enum DomainSchema{
export enum DomainSchema {
HTTP = 'HTTP',
HTTPS = 'HTTPS',
MIXED = 'MIXED'
@ -52,34 +36,13 @@ export const domainSchemaTranslations = new Map<DomainSchema, string>(
]
);
export enum MapperConfigType{
BASIC = 'BASIC',
CUSTOM = 'CUSTOM',
GITHUB = 'GITHUB',
APPLE = 'APPLE'
}
export enum TenantNameStrategy{
DOMAIN = 'DOMAIN',
EMAIL = 'EMAIL',
CUSTOM = 'CUSTOM'
}
export enum PlatformType {
WEB = 'WEB',
ANDROID = 'ANDROID',
IOS = 'IOS'
}
export const platformTypeTranslations = new Map<PlatformType, string>(
[
[PlatformType.WEB, 'admin.oauth2.platform-web'],
[PlatformType.ANDROID, 'admin.oauth2.platform-android'],
[PlatformType.IOS, 'admin.oauth2.platform-ios']
]
);
export interface OAuth2ClientRegistrationTemplate extends OAuth2RegistrationInfo{
export interface OAuth2ClientRegistrationTemplate extends OAuth2RegistrationInfo {
comment: string;
createdTime: number;
helpLink: string;
@ -101,7 +64,7 @@ export interface OAuth2RegistrationInfo {
userInfoUri: string;
clientAuthenticationMethod: ClientAuthenticationMethod;
userNameAttributeName: string;
mapperConfig: MapperConfig;
mapperConfig: OAuth2MapperConfig;
additionalInfo: string;
}
@ -110,33 +73,132 @@ export enum ClientAuthenticationMethod {
POST = 'POST'
}
export interface MapperConfig {
export interface Domain extends BaseData<DomainId>, HasTenantId {
tenantId?: TenantId;
name: string;
oauth2Enabled: boolean;
propagateToEdge: boolean;
}
export interface HasOauth2Clients {
oauth2ClientInfos?: Array<OAuth2ClientInfo>;
}
export interface DomainInfo extends Domain, HasOauth2Clients {
oauth2ClientInfos?: Array<OAuth2ClientInfo>;
}
export interface MobileApp extends BaseData<MobileAppId>, HasTenantId {
tenantId?: TenantId;
pkgName: string;
appSecret: string;
oauth2Enabled: boolean;
}
export interface MobileAppInfo extends MobileApp, HasOauth2Clients {
oauth2ClientInfos?: Array<OAuth2ClientInfo>;
}
export interface OAuth2Client extends BaseData<OAuth2ClientId>, HasTenantId {
tenantId?: TenantId;
title: string;
mapperConfig: OAuth2MapperConfig;
clientId: string;
clientSecret: string;
authorizationUri: string;
accessTokenUri: string;
scope: Array<string>;
userInfoUri?: string;
userNameAttributeName: string;
jwkSetUri?: string;
clientAuthenticationMethod: ClientAuthenticationMethod;
loginButtonLabel: string;
loginButtonIcon?: string;
platforms?: Array<PlatformType>;
additionalInfo: any;
}
export interface OAuth2MapperConfig {
allowUserCreation: boolean;
activateUser: boolean;
type: MapperConfigType;
basic?: MapperConfigBasic;
custom?: MapperConfigCustom;
type: MapperType;
basic?: OAuth2BasicMapperConfig;
custom?: OAuth2CustomMapperConfig
}
export enum MapperType {
BASIC = 'BASIC',
CUSTOM = 'CUSTOM',
GITHUB = 'GITHUB',
APPLE = 'APPLE'
}
export interface MapperConfigBasic {
emailAttributeKey: string;
export interface OAuth2BasicMapperConfig {
emailAttributeKey?: string;
firstNameAttributeKey?: string;
lastNameAttributeKey?: string;
tenantNameStrategy: TenantNameStrategy;
tenantNameStrategy?: TenantNameStrategyType;
tenantNamePattern?: string;
customerNamePattern?: string;
defaultDashboardName?: string;
alwaysFullScreen?: boolean;
}
export interface MapperConfigCustom {
url: string;
export enum TenantNameStrategyType {
DOMAIN = 'DOMAIN',
EMAIL = 'EMAIL',
CUSTOM = 'CUSTOM'
}
export interface OAuth2CustomMapperConfig {
url?: string;
username?: string;
password?: string;
sendToken: boolean;
}
export const platformTypeTranslations = new Map<PlatformType, string>(
[
[PlatformType.WEB, 'admin.oauth2.platform-web'],
[PlatformType.ANDROID, 'admin.oauth2.platform-android'],
[PlatformType.IOS, 'admin.oauth2.platform-ios']
]
);
export interface OAuth2ClientInfo extends BaseData<OAuth2ClientId> {
title: string;
platforms?: Array<PlatformType>;
}
export interface OAuth2ClientInfo {
export interface OAuth2ClientLoginInfo {
name: string;
icon?: string;
icon: string;
url: string;
}
export function getProviderHelpLink(provider: Provider): string {
if (provider) {
if (providerHelpLinkMap.has(provider)) {
return providerHelpLinkMap.get(provider);
}
}
return 'oauth2Settings';
}
export enum Provider {
CUSTOM = 'Custom',
FACEBOOK = 'Facebook',
GOOGLE = 'Google',
GITHUB = 'Github',
APPLE = 'Apple'
}
const providerHelpLinkMap = new Map<Provider, string>(
[
[Provider.CUSTOM, 'oauth2Settings'],
[Provider.APPLE, 'oauth2Apple'],
[Provider.FACEBOOK, 'oauth2Facebook'],
[Provider.GITHUB, 'oauth2Github'],
[Provider.GOOGLE, 'oauth2Google'],
]
)

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

@ -222,6 +222,27 @@
"always-fullscreen": "Always fullscreen",
"authorization-uri": "Authorization URI",
"authorization-uri-required": "Authorization URI is required.",
"add-client": "Add OAuth 2.0 client",
"client-details": "OAuth 2.0 details",
"client": "OAuth 2.0 client",
"clients": "OAuth 2.0 clients",
"no-oauth2-clients": "No OAuth 2.0 clients found",
"search-oauth2-clients": "Search OAuth 2.0 clients",
"delete-client-title": "Are you sure you want to delete the OAuth 2.0 client '{{clientName}}'?",
"delete-client-text": "Be careful, after the confirmation the client and all related data will become unrecoverable.",
"delete-mobile-app-title": "Are you sure you want to delete the mobile application '{{applicationName}}'?",
"delete-mobile-app-text": "Be careful, after the confirmation the mobile application and all related data will become unrecoverable.",
"title": "Title",
"client-title-required": "Title is required",
"advanced-settings": "Advanced settings",
"domain-details": "Domain details",
"no-domains": "No domains found",
"search-domains": "Search domains",
"mobile-app-details": "Mobile application details",
"add-mobile-app": "Add mobile application",
"no-mobile-apps": "Search mobile applications",
"search-mobile-apps": "No mobile applications found",
"send-token": "Send token",
"client-authentication-method": "Client authentication method",
"client-id": "Client ID",
"client-id-required": "Client ID is required.",
@ -254,7 +275,7 @@
"login-provider": "Login provider",
"mapper": "Mapper",
"new-domain": "New domain",
"oauth2": "OAuth2",
"oauth2": "OAuth 2.0",
"password-max-length": "Password should be less than 256",
"redirect-uri-template": "Redirect URI template",
"copy-redirect-uri": "Copy redirect URI",
@ -282,11 +303,13 @@
"domain-schema-http": "HTTP",
"domain-schema-https": "HTTPS",
"domain-schema-mixed": "HTTP+HTTPS",
"enable": "Enable OAuth2 settings",
"edge-enable": "Propagate to Edge",
"enable": "Enable OAuth 2.0 settings",
"disable": "Disable OAuth 2.0 settings",
"edge": "Propagate to Edge",
"edge-enable": "Enable propagation to Edge",
"edge-disable": "Disable propagation to Edge",
"domains": "Domains",
"mobile-apps": "Mobile applications",
"no-mobile-apps": "No applications configured",
"mobile-package": "Application package",
"mobile-package-placeholder": "Ex.: my.example.app",
"mobile-package-hint": "For Android: your own unique Application ID. For iOS: Product bundle identifier.",
@ -298,7 +321,6 @@
"mobile-app-secret-base64": "Application secret must be base64 format.",
"invalid-mobile-app-secret": "Application secret must contain only alphanumeric characters and must be between 16 and 2048 characters long.",
"copy-mobile-app-secret": "Copy application secret",
"add-mobile-app": "Add application",
"delete-mobile-app": "Delete application info",
"providers": "Providers",
"platform-web": "Web",
@ -312,6 +334,7 @@
"provider": "Provider",
"redirect-url": "Redirect URI",
"domain-name": "Domain name",
"domain-name-required": "Domain name is required",
"redirect-url-template": "Redirect URI template",
"microsoft-tenant-id": "Directory (tenant) Id",
"microsoft-tenant-id-required": "Directory (tenant) Id is required",
@ -2300,7 +2323,16 @@
"type-notification-request": "Notification request",
"type-notification-template": "Notification template",
"type-notification-templates": "Notification templates",
"list-of-notification-templates": "{ count, plural, =1 {One notification template} other {List of # notification templates} }"
"list-of-notification-templates": "{ count, plural, =1 {One notification template} other {List of # notification templates} }",
"type-oauth2-client": "OAuth 2.0 client",
"type-oauth2-clients": "OAuth 2.0 clients",
"list-of-oauth2-clients": "{ count, plural, =1 {One OAuth 2.0 client} other {List of # OAuth 2.0 clients} }",
"type-domain": "Domain",
"type-domains": "Domains",
"list-of-domains": "{ count, plural, =1 {One Domain} other {List of # Domains} }",
"type-mobile-app": "Mobile application",
"type-mobile-apps": "Mobile applications",
"list-of-mobile-apps": "{ count, plural, =1 {One Mobile application} other {List of # Mobile applications} }"
},
"entity-field": {
"created-time": "Created time",

Loading…
Cancel
Save