Browse Source

UI: Add mobile center menu item and implements application page

pull/11835/head
Vladyslav_Prykhodko 2 years ago
parent
commit
5790ee1642
  1. 1
      common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppStatus.java
  2. 36
      ui-ngx/src/app/core/http/mobile-app.service.ts
  3. 12
      ui-ngx/src/app/core/http/mobile-application.service.ts
  4. 68
      ui-ngx/src/app/core/services/menu.models.ts
  5. 4
      ui-ngx/src/app/modules/home/components/entity/entities-table.component.html
  6. 11
      ui-ngx/src/app/modules/home/components/entity/entities-table.component.scss
  7. 8
      ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts
  8. 6
      ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts
  9. 143
      ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-config.resolver.ts
  10. 76
      ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.html
  11. 106
      ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.ts
  12. 18
      ui-ngx/src/app/modules/home/pages/admin/oauth2/oauth2-routing.module.ts
  13. 6
      ui-ngx/src/app/modules/home/pages/admin/oauth2/oauth2.module.ts
  14. 2
      ui-ngx/src/app/modules/home/pages/home-pages.module.ts
  15. 84
      ui-ngx/src/app/modules/home/pages/mobile/applications/applications-routing.module.ts
  16. 37
      ui-ngx/src/app/modules/home/pages/mobile/applications/applications.module.ts
  17. 151
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts
  18. 12
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.html
  19. 23
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.scss
  20. 10
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.ts
  21. 145
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html
  22. 18
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.scss
  23. 153
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts
  24. 78
      ui-ngx/src/app/modules/home/pages/mobile/mobile-routing.module.ts
  25. 33
      ui-ngx/src/app/modules/home/pages/mobile/mobile.module.ts
  26. 7
      ui-ngx/src/app/shared/models/entity-type.models.ts
  27. 27
      ui-ngx/src/app/shared/models/id/mobile-app-bundle-id.ts
  28. 2
      ui-ngx/src/app/shared/models/id/public-api.ts
  29. 95
      ui-ngx/src/app/shared/models/mobile-app.models.ts
  30. 12
      ui-ngx/src/app/shared/models/oauth2.models.ts
  31. 45
      ui-ngx/src/assets/locale/locale.constant-en_US.json

1
common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppStatus.java

@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.mobile;
public enum MobileAppStatus {
DRAFT,
PUBLISHED,
DEPRECATED,
SUSPENDED

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

@ -18,9 +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 { MobileApp, MobileAppInfo } from '@shared/models/oauth2.models';
import { PageLink } from '@shared/models/page/page-link';
import { PageData } from '@shared/models/page/page-data';
import { MobileApp, MobileAppBundle, MobileAppBundleInfo } from '@shared/models/mobile-app.models';
@Injectable({
providedIn: 'root'
@ -32,25 +32,37 @@ export class MobileAppService {
) {
}
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 saveMobileApp(mobileApp: MobileApp, config?: RequestConfig): Observable<MobileApp> {
return this.http.post<MobileApp>(`/api/mobile/app`, 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<MobileApp>> {
return this.http.get<PageData<MobileApp>>(`/api/mobile/app${pageLink.toQuery()}`, 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 getMobileAppInfoById(id: string, config?: RequestConfig): Observable<MobileApp> {
return this.http.get<MobileApp>(`/api/mobile/app/${id}`, defaultHttpOptionsFromConfig(config));
}
public getMobileAppInfoById(id: string, config?: RequestConfig): Observable<MobileAppInfo> {
return this.http.get<MobileAppInfo>(`/api/mobileApp/info/${id}`, defaultHttpOptionsFromConfig(config));
public deleteMobileApp(id: string, config?: RequestConfig): Observable<void> {
return this.http.delete<void>(`/api/mobile/app/${id}`, defaultHttpOptionsFromConfig(config));
}
public deleteMobileApp(id: string, config?: RequestConfig): Observable<void> {
return this.http.delete<void>(`/api/mobileApp/${id}`, defaultHttpOptionsFromConfig(config));
public saveMobileAppBundle(mobileAppBundle: MobileAppBundle, oauth2ClientIds?: Array<string>, config?: RequestConfig) {
return this.http.post<MobileApp>(`/api/mobile/bundle${oauth2ClientIds ? '?oauth2ClientIds=' + oauth2ClientIds.join(',') : ''}`,
mobileAppBundle, defaultHttpOptionsFromConfig(config));
}
public updateOauth2Clients(id: string, oauth2ClientIds?: Array<string>, config?: RequestConfig) {
return this.http.put(`/mobile/bundle/${id}/oauth2Clients`, oauth2ClientIds, defaultHttpOptionsFromConfig(config));
}
public getTenantMobileAppBundleInfos(pageLink: PageLink, config?: RequestConfig): Observable<PageData<MobileAppBundleInfo>> {
return this.http.get<PageData<MobileAppBundleInfo>>(`/api/mobile/bundle/infos${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public getMobileAppBundleInfoById(id: string, config?: RequestConfig): Observable<MobileAppBundleInfo> {
return this.http.get<MobileAppBundleInfo>(`/api/mobile/bundle/infos/${id}`, defaultHttpOptionsFromConfig(config));
}
}

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

@ -18,7 +18,7 @@ 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';
import { QrCodeSettings } from '@shared/models/mobile-app.models';
@Injectable({
providedIn: 'root'
@ -29,16 +29,16 @@ export class MobileApplicationService {
private http: HttpClient
) {}
public getMobileAppSettings(config?: RequestConfig): Observable<MobileAppSettings> {
return this.http.get<MobileAppSettings>(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config));
public getMobileAppSettings(config?: RequestConfig): Observable<QrCodeSettings> {
return this.http.get<QrCodeSettings>(`/api/mobile/qr/settings`, defaultHttpOptionsFromConfig(config));
}
public saveMobileAppSettings(mobileAppSettings: MobileAppSettings, config?: RequestConfig): Observable<MobileAppSettings> {
return this.http.post<MobileAppSettings>(`/api/mobile/app/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config));
public saveMobileAppSettings(mobileAppSettings: QrCodeSettings, config?: RequestConfig): Observable<QrCodeSettings> {
return this.http.post<QrCodeSettings>(`/api/mobile/qr/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config));
}
public getMobileAppDeepLink(config?: RequestConfig): Observable<string> {
return this.http.get<string>(`/api/mobile/deepLink`, defaultHttpOptionsFromConfig(config));
return this.http.get<string>(`/api/mobile/qr/deepLink`, defaultHttpOptionsFromConfig(config));
}
}

68
ui-ngx/src/app/core/services/menu.models.ts

@ -65,6 +65,9 @@ export enum MenuId {
notification_recipients = 'notification_recipients',
notification_templates = 'notification_templates',
notification_rules = 'notification_rules',
mobile_center = 'mobile_center',
mobile_apps = 'mobile_apps',
mobile_app_settings = 'mobile_app_settings',
settings = 'settings',
general = 'general',
mail_server = 'mail_server',
@ -73,13 +76,11 @@ export enum MenuId {
repository_settings = 'repository_settings',
auto_commit_settings = 'auto_commit_settings',
queues = 'queues',
mobile_app_settings = 'mobile_app_settings',
security_settings = 'security_settings',
security_settings_general = 'security_settings_general',
two_fa = 'two_fa',
oauth2 = 'oauth2',
domains = 'domains',
mobile_apps = 'mobile_apps',
clients = 'clients',
audit_log = 'audit_log',
alarms = 'alarms',
@ -271,6 +272,37 @@ export const menuSectionMap = new Map<MenuId, MenuSection>([
icon: 'mdi:message-cog'
}
],
[
MenuId.mobile_center,
{
id: MenuId.mobile_center,
name: 'mobile.mobile-center',
type: 'link',
path: '/mobile-center',
icon: 'smartphone'
}
],
[
MenuId.mobile_apps,
{
id: MenuId.mobile_apps,
name: 'mobile.applications',
type: 'link',
path: '/mobile-center/applications',
icon: 'list'
}
],
[
MenuId.mobile_app_settings,
{
id: MenuId.mobile_app_settings,
name: 'admin.mobile-app.mobile-app',
fullName: 'admin.mobile-app.mobile-app',
type: 'link',
path: '/mobile-center/mobile-app',
icon: 'smartphone'
}
],
[
MenuId.settings,
{
@ -356,17 +388,6 @@ export const menuSectionMap = new Map<MenuId, MenuSection>([
icon: 'swap_calls'
}
],
[
MenuId.mobile_app_settings,
{
id: MenuId.mobile_app_settings,
name: 'admin.mobile-app.mobile-app',
fullName: 'admin.mobile-app.mobile-app',
type: 'link',
path: '/settings/mobile-app',
icon: 'smartphone'
}
],
[
MenuId.security_settings,
{
@ -418,16 +439,6 @@ export const menuSectionMap = new Map<MenuId, MenuSection>([
icon: 'domain'
}
],
[
MenuId.mobile_apps,
{
id: MenuId.mobile_apps,
name: 'admin.oauth2.mobile-apps',
type: 'link',
path: '/security-settings/oauth2/mobile-applications',
icon: 'smartphone'
}
],
[
MenuId.clients,
{
@ -687,14 +698,20 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
{id: MenuId.notification_rules}
]
},
{
id: MenuId.mobile_center,
pages: [
{id: MenuId.mobile_apps},
{id: MenuId.mobile_app_settings}
]
},
{
id: MenuId.settings,
pages: [
{id: MenuId.general},
{id: MenuId.mail_server},
{id: MenuId.notification_settings},
{id: MenuId.queues},
{id: MenuId.mobile_app_settings}
{id: MenuId.queues}
]
},
{
@ -706,7 +723,6 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
id: MenuId.oauth2,
pages: [
{id: MenuId.domains},
{id: MenuId.mobile_apps},
{id: MenuId.clients}
]
}

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

@ -159,7 +159,8 @@
[fxHide.lt-lg]="column.mobileHide"
*matHeaderCellDef [ngStyle]="headerCellStyle(column)" mat-sort-header [disabled]="!column.sortable">
{{ column.ignoreTranslate ? column.title : (column.title | translate) }} </mat-header-cell>
<mat-cell [ngClass]="{'mat-number-cell': column.isNumberColumn}"
<mat-cell [ngClass]="{'mat-number-cell': column.isNumberColumn,
'cell-action': column.actionCell?.type === cellActionType.COPY_BUTTON}"
[fxHide.lt-lg]="column.mobileHide"
*matCellDef="let entity; let row = index"
[matTooltip]="cellTooltip(entity, column, row)"
@ -182,6 +183,7 @@
<ng-container [ngSwitch]="column.actionCell.type">
<ng-template [ngSwitchCase]="cellActionType.COPY_BUTTON">
<tb-copy-button
class="copy-button"
[disabled]="isLoading$ | async"
[fxShow]="column.actionCell.isEnabled(entity)"
[copyText]="column.actionCell.onAction(null, entity)"

11
ui-ngx/src/app/modules/home/components/entity/entities-table.component.scss

@ -55,6 +55,17 @@
.table-container {
overflow: auto;
.copy-button {
visibility: hidden;
transition: visibility 0.1s;
}
.cell-action:hover {
.copy-button {
visibility: visible;
}
}
}
.tb-entity-table-info{

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

@ -18,7 +18,7 @@ import { ChangeDetectorRef, Component, ElementRef, Input, NgZone, OnDestroy, OnI
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 { BadgePosition, QrCodeSettings } from '@shared/models/mobile-app.models';
import { MobileApplicationService } from '@core/http/mobile-application.service';
import { WidgetContext } from '@home/models/widget-component.models';
import { UtilsService } from '@core/services/utils.service';
@ -39,7 +39,7 @@ export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnI
private readonly destroy$ = new Subject<void>();
private widgetResize$: ResizeObserver;
private mobileAppSettingsValue: MobileAppSettings;
private mobileAppSettingsValue: QrCodeSettings;
private deepLink: string;
private deepLinkTTL: number;
private deepLinkTTLTimeoutID: NodeJS.Timeout;
@ -65,13 +65,13 @@ export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnI
widgetTitlePanel: TemplateRef<any>;
@Input()
set mobileAppSettings(settings: MobileAppSettings) {
set mobileAppSettings(settings: QrCodeSettings) {
if (settings) {
this.mobileAppSettingsValue = settings;
}
};
get mobileAppSettings(): MobileAppSettings {
get mobileAppSettings(): QrCodeSettings {
return this.mobileAppSettingsValue;
}

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

@ -25,7 +25,7 @@ import { MobileApplicationService } from '@core/http/mobile-application.service'
import {
BadgePosition,
badgePositionTranslationsMap,
MobileAppSettings
QrCodeSettings
} from '@shared/models/mobile-app.models';
import { ActionUpdateMobileQrCodeEnabled } from '@core/auth/auth.actions';
@ -38,7 +38,7 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf
mobileAppSettingsForm: FormGroup;
mobileAppSettings: MobileAppSettings;
mobileAppSettings: QrCodeSettings;
private readonly destroy$ = new Subject<void>();
@ -150,7 +150,7 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf
});
}
private processMobileAppSettings(mobileAppSettings: MobileAppSettings): void {
private processMobileAppSettings(mobileAppSettings: QrCodeSettings): void {
this.mobileAppSettings = {...mobileAppSettings};
this.mobileAppSettingsForm.reset(this.mobileAppSettings);
}

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

@ -1,143 +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 { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
import {
CellActionDescriptorType,
DateEntityTableColumn,
EntityActionTableColumn,
EntityChipsEntityTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { MobileApp, MobileAppInfo } from '@shared/models/oauth2.models';
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';
import { map, Observable, of, mergeMap } from 'rxjs';
@Injectable()
export class MobileAppTableConfigResolver {
private readonly config: EntityTableConfig<MobileAppInfo> = new EntityTableConfig<MobileAppInfo>();
constructor(private translate: TranslateService,
private datePipe: DatePipe,
private mobileAppService: MobileAppService) {
this.config.tableTitle = this.translate.instant('admin.oauth2.mobile-apps');
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.addDialogStyle = {width: '850px', maxHeight: '100vh'};
this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
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: '4px',
'font-size': '16px',
color: 'rgba(0,0,0,.87)'
},
isEnabled: (entity) => !!entity.appSecret,
onAction: ($event, entity) => entity.appSecret,
type: CellActionDescriptorType.COPY_BUTTON
}),
new EntityChipsEntityTableColumn<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, originalMobileApp) => {
const clientsIds = mobileApp.oauth2ClientInfos as Array<string> || [];
let clientsTask: Observable<void>;
if (mobileApp.id && !isEqual(mobileApp.oauth2ClientInfos?.sort(),
originalMobileApp.oauth2ClientInfos?.map(info => info.id ? info.id.id : info).sort())) {
clientsTask = this.mobileAppService.updateOauth2Clients(mobileApp.id.id, clientsIds);
} else {
clientsTask = of(null);
}
delete mobileApp.oauth2ClientInfos;
return clientsTask.pipe(
mergeMap(() => this.mobileAppService.saveMobileApp(mobileApp as MobileApp, mobileApp.id ? [] : clientsIds)),
map(savedMobileApp => {
(savedMobileApp as MobileAppInfo).oauth2ClientInfos = clientsIds;
return savedMobileApp;
})
);
};
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 > 35) {
text = `${text.slice(0, 35)}`;
}
return text;
}
}

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

@ -1,76 +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.
-->
<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-error *ngIf="entityForm.get('pkgName').hasError('maxlength')">
{{ 'admin.oauth2.mobile-package-max-length' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('pkgName').hasError('pattern')">
{{ 'admin.oauth2.mobile-package-spaces' | 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-entity-list [entityType]="entityType.OAUTH2_CLIENT" formControlName="oauth2ClientInfos"
labelText="{{ 'admin.oauth2.clients' | translate }}"
placeholderText="{{ 'admin.oauth2.add-client' | translate }}">
<button mat-button color="primary" matSuffix
[disabled]="!isEdit"
(click)="createClient($event)">
<span style="white-space: nowrap">{{ 'admin.oauth2.create-new' | translate }}</span>
</button>
</tb-entity-list>
</section>

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

@ -1,106 +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 { 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 { 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 { isDefinedAndNotNull, randomAlphanumeric } from '@core/utils';
import { MatDialog } from '@angular/material/dialog';
import { ClientDialogComponent } from '@home/pages/admin/oauth2/clients/client-dialog.component';
import { EntityType } from '@shared/models/entity-type.models';
@Component({
selector: 'tb-mobile-app',
templateUrl: './mobile-app.component.html',
styleUrls: []
})
export class MobileAppComponent extends EntityComponent<MobileAppInfo> {
entityType = EntityType;
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
@Inject('entity') protected entityValue: MobileAppInfo,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<MobileAppInfo>,
protected cd: ChangeDetectorRef,
public fb: UntypedFormBuilder,
private dialog: MatDialog) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
}
buildForm(entity: MobileAppInfo): UntypedFormGroup {
return this.fb.group({
pkgName: [entity?.pkgName ? entity.pkgName : '', [Validators.required, Validators.maxLength(255),
Validators.pattern(/^\S+$/)]],
appSecret: [entity?.appSecret ? entity.appSecret : btoa(randomAlphanumeric(64)),
[Validators.required, this.base64Format]],
oauth2Enabled: isDefinedAndNotNull(entity?.oauth2Enabled) ? entity.oauth2Enabled : true,
oauth2ClientInfos: entity?.oauth2ClientInfos ? entity.oauth2ClientInfos.map(info => info.id.id) : []
});
}
updateForm(entity: MobileAppInfo) {
this.entityForm.patchValue({
pkgName: entity.pkgName,
appSecret: entity.appSecret,
oauth2Enabled: entity.oauth2Enabled,
oauth2ClientInfos: entity.oauth2ClientInfos?.map(info => info.id ? info.id.id : info)
});
}
createClient($event: Event) {
if ($event) {
$event.stopPropagation();
$event.preventDefault();
}
this.dialog.open<ClientDialogComponent>(ClientDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {}
}).afterClosed()
.subscribe((client) => {
if (client) {
const formValue = this.entityForm.get('oauth2ClientInfos').value ?
[...this.entityForm.get('oauth2ClientInfos').value] : [];
formValue.push(client.id.id);
this.entityForm.get('oauth2ClientInfos').patchValue(formValue);
this.entityForm.get('oauth2ClientInfos').markAsDirty();
}
});
}
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};
}
}
}

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

@ -26,7 +26,6 @@ import { DomainTableConfigResolver } from '@home/pages/admin/oauth2/domains/doma
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';
import { MenuId } from '@core/services/menu.models';
@Injectable()
@ -74,20 +73,6 @@ export const oAuth2Routes: Routes = [
entitiesTableConfig: DomainTableConfigResolver
}
},
{
path: 'mobile-applications',
component: EntitiesTableComponent,
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.oauth2.mobile-apps',
breadcrumb: {
menuId: MenuId.mobile_apps
}
},
resolve: {
entitiesTableConfig: MobileAppTableConfigResolver
}
},
{
path: 'clients',
data: {
@ -140,8 +125,7 @@ export const oAuth2Routes: Routes = [
providers: [
OAuth2LoginProcessingUrlResolver,
ClientsTableConfigResolver,
DomainTableConfigResolver,
MobileAppTableConfigResolver
DomainTableConfigResolver
],
imports: [RouterModule.forChild(oAuth2Routes)],
exports: [RouterModule]

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

@ -24,8 +24,6 @@ import { ClientTableHeaderComponent } from '@home/pages/admin/oauth2/clients/cli
import { DomainComponent } from '@home/pages/admin/oauth2/domains/domain.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: [
@ -33,9 +31,7 @@ import { MobileAppTableHeaderComponent } from '@home/pages/admin/oauth2/mobile-a
ClientDialogComponent,
ClientTableHeaderComponent,
DomainComponent,
DomainTableHeaderComponent,
MobileAppComponent,
MobileAppTableHeaderComponent
DomainTableHeaderComponent
],
imports: [
Oauth2RoutingModule,

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

@ -44,6 +44,7 @@ import { FeaturesModule } from '@home/pages/features/features.module';
import { NotificationModule } from '@home/pages/notification/notification.module';
import { AccountModule } from '@home/pages/account/account.module';
import { ScadaSymbolModule } from '@home/pages/scada-symbol/scada-symbol.module';
import { MobileModule } from '@home/pages/mobile/mobile.module';
@NgModule({
exports: [
@ -58,6 +59,7 @@ import { ScadaSymbolModule } from '@home/pages/scada-symbol/scada-symbol.module'
ProfilesModule,
EntitiesModule,
FeaturesModule,
MobileModule,
NotificationModule,
DeviceModule,
AssetModule,

84
ui-ngx/src/app/modules/home/pages/mobile/applications/applications-routing.module.ts

@ -0,0 +1,84 @@
///
/// 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 { RouterModule, Routes } from '@angular/router';
import { Authority } from '@shared/models/authority.enum';
import { MenuId } from '@core/services/menu.models';
import { EntitiesTableComponent } from '@home/components/entity/entities-table.component';
import { MobileAppTableConfigResolver } from '@home/pages/mobile/applications/mobile-app-table-config.resolver';
import { DevicesTableConfigResolver } from '@home/pages/device/devices-table-config.resolver';
import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component';
import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models';
import { BreadCrumbConfig } from '@shared/components/breadcrumb';
export const applicationsRoutes: Routes = [
{
path: 'applications',
data: {
breadcrumb: {
menuId: MenuId.mobile_apps
}
},
children: [
{
path: '',
component: EntitiesTableComponent,
data: {
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'mobile.applications',
},
resolve: {
entitiesTableConfig: MobileAppTableConfigResolver
}
},
{
path: ':entityId',
component: EntityDetailsPageComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
breadcrumb: {
labelFunction: entityDetailsPageBreadcrumbLabelFunction,
icon: 'list'
} as BreadCrumbConfig<EntityDetailsPageComponent>,
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'mobile.applications',
},
resolve: {
entitiesTableConfig: MobileAppTableConfigResolver
}
}
]
}
];
const routes: Routes = [
{
path: 'security-settings/oauth2/mobile-applications',
pathMatch: 'full',
redirectTo: '/mobile-center/applications'
}
];
@NgModule({
providers: [
MobileAppTableConfigResolver
],
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ApplicationsRoutingModule { }

37
ui-ngx/src/app/modules/home/pages/mobile/applications/applications.module.ts

@ -0,0 +1,37 @@
///
/// 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 { MobileAppComponent } from '@home/pages/mobile/applications/mobile-app.component';
import { MobileAppTableHeaderComponent } from '@home/pages/mobile/applications/mobile-app-table-header.component';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module';
import { HomeComponentsModule } from '@home/components/home-components.module';
import { ApplicationsRoutingModule } from '@home/pages/mobile/applications/applications-routing.module';
@NgModule({
declarations: [
MobileAppComponent,
MobileAppTableHeaderComponent
],
imports: [
CommonModule,
SharedModule,
HomeComponentsModule,
ApplicationsRoutingModule
]
})
export class ApplicationModule { }

151
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts

@ -0,0 +1,151 @@
///
/// 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 } from '@angular/router';
import {
CellActionDescriptorType,
DateEntityTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { TranslateService } from '@ngx-translate/core';
import { DatePipe } from '@angular/common';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { Direction } from '@app/shared/models/page/sort-order';
import { MobileAppService } from '@core/http/mobile-app.service';
import { MobileAppComponent } from '@home/pages/mobile/applications/mobile-app.component';
import { MobileAppTableHeaderComponent } from '@home/pages/mobile/applications/mobile-app-table-header.component';
import { MobileApp, MobileAppStatus, mobileAppStatusTranslations } from '@shared/models/mobile-app.models';
import { platformTypeTranslations } from '@shared/models/oauth2.models';
import { TruncatePipe } from '@shared/pipe/truncate.pipe';
@Injectable()
export class MobileAppTableConfigResolver {
private readonly config: EntityTableConfig<MobileApp> = new EntityTableConfig<MobileApp>();
constructor(private translate: TranslateService,
private datePipe: DatePipe,
private mobileAppService: MobileAppService,
private truncatePipe: TruncatePipe) {
this.config.selectionEnabled = false;
this.config.entityType = EntityType.MOBILE_APP;
this.config.addEnabled = false;
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.addDialogStyle = {width: '850px', maxHeight: '100vh'};
this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.config.columns.push(
new DateEntityTableColumn<MobileApp>('createdTime', 'common.created-time', this.datePipe, '170px'),
new EntityTableColumn<MobileApp>('pkgName', 'mobile.application-package', '20%', (entity) => entity.pkgName ?? '', () => ({}),
false, () => ({}), () => undefined, false,
{
name: this.translate.instant('mobile.copy-application-package'),
icon: 'content_copy',
style: {
padding: '4px',
'font-size': '16px',
color: 'rgba(0,0,0,.54)'
},
isEnabled: (entity) => !!entity.pkgName,
onAction: (_$event, entity) => entity.pkgName,
type: CellActionDescriptorType.COPY_BUTTON
}),
new EntityTableColumn<MobileApp>('appSecret', 'mobile.application-secret', '15%',
(entity) => this.truncatePipe.transform(entity.appSecret, true, 10, '…'), () => ({}),
false, () => ({}), () => undefined, false,
{
name: this.translate.instant('mobile.copy-application-secret'),
icon: 'content_copy',
style: {
padding: '4px',
'font-size': '16px',
color: 'rgba(0,0,0,.54)'
},
isEnabled: (entity) => !!entity.appSecret,
onAction: (_$event, entity) => entity.appSecret,
type: CellActionDescriptorType.COPY_BUTTON
}),
new EntityTableColumn<MobileApp>('platformType', 'mobile.platform-type', '15%',
(entity) => this.translate.instant(platformTypeTranslations.get(entity.platformType))
),
new EntityTableColumn<MobileApp>('status', 'mobile.status', '15%',
(entity) => `<span style="display: flex;">${this.mobileStatus(entity.status)}</span>`,
(entity)=> this.mobileStatusStyle(entity.status)
),
new EntityTableColumn<MobileApp>('minVersion', 'mobile.min-version', '15%',
(entity) => entity.versionInfo?.minVersion ?? '', () => ({}), false),
new EntityTableColumn<MobileApp>('latestVersion', 'mobile.latest-version', '15%',
(entity) => entity.versionInfo?.latestVersion ?? '', () => ({}), false),
);
this.config.deleteEntityTitle = (app) => this.translate.instant('mobile.delete-applications-title', {applicationName: app.pkgName});
this.config.deleteEntityContent = () => this.translate.instant('mobile.delete-applications-text');
this.config.entitiesFetchFunction = pageLink => this.mobileAppService.getTenantMobileAppInfos(pageLink);
this.config.loadEntity = id => this.mobileAppService.getMobileAppInfoById(id.id);
this.config.saveEntity = (mobileApp) => this.mobileAppService.saveMobileApp(mobileApp);
this.config.deleteEntity = id => this.mobileAppService.deleteMobileApp(id.id);
}
resolve(_route: ActivatedRouteSnapshot): EntityTableConfig<MobileApp> {
return this.config;
}
private mobileStatus(status: MobileAppStatus): string {
const translateKey = mobileAppStatusTranslations.get(status);
let backgroundColor = 'rgba(25, 128, 56, 0.06)';
switch (status) {
case MobileAppStatus.DEPRECATED:
backgroundColor = 'rgba(250, 164, 5, 0.06)';
break;
case MobileAppStatus.SUSPENDED:
backgroundColor = 'rgba(209, 39, 48, 0.06)';
break;
case MobileAppStatus.DRAFT:
backgroundColor = 'rgba(160, 160, 160, 0.06)';
break;
}
return `<div style="border-radius: 14px; height: 28px; line-height: 20px; padding: 4px 10px;
width: fit-content; background-color: ${backgroundColor}">
${this.translate.instant(translateKey)}
</div>`;
}
private mobileStatusStyle(status: MobileAppStatus): object {
const styleObj = {
fontSize: '14px',
color: '#198038'
};
switch (status) {
case MobileAppStatus.DEPRECATED:
styleObj.color = '#FAA405';
break;
case MobileAppStatus.SUSPENDED:
styleObj.color = '#D12730';
break;
case MobileAppStatus.DRAFT:
styleObj.color = '#A0A0A0';
break;
}
return styleObj;
}
}

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

@ -15,4 +15,14 @@
limitations under the License.
-->
<div tb-help="oauth2Settings" style="margin-left: -20px"></div>
<div fxFlex="100" fxLayout="row" fxLayoutAlign="space-between center" fxLayoutAlign.xs="start center">
<section fxHide fxShow.gt-xs class="tb-entity-title-container">
<div tbTruncateWithTooltip translate>mobile.applications</div>
<!-- <div tb-help="oauth2Settings"></div>-->
</section>
<button mat-stroked-button color="primary" (click)="createMobile($event)">
<div fxLayout="row" fxLayoutAlign="start center">
<mat-icon>add</mat-icon>{{ 'mobile.add-application' | translate }}
</div>
</button>
</div>

23
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.scss

@ -0,0 +1,23 @@
/**
* 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{
width: 100000px;
.tb-entity-title-container {
display: flex;
align-items: center;
}
}

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

@ -18,16 +18,20 @@ 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';
import { MobileApp } from '@shared/models/mobile-app.models';
@Component({
selector: 'tb-mobile-app-table-header',
templateUrl: './mobile-app-table-header.component.html',
styleUrls: []
styleUrls: ['./mobile-app-table-header.component.scss']
})
export class MobileAppTableHeaderComponent extends EntityTableHeaderComponent<MobileAppInfo> {
export class MobileAppTableHeaderComponent extends EntityTableHeaderComponent<MobileApp> {
constructor(protected store: Store<AppState>) {
super(store);
}
createMobile($event: Event) {
this.entitiesTableConfig.getTable().addEntity($event);
}
}

145
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html

@ -0,0 +1,145 @@
<!--
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="mat-padding" [formGroup]="entityForm" fxLayout="column">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label translate>mobile.mobile-package</mat-label>
<input matInput required formControlName="pkgName">
<tb-copy-button
matSuffix
miniButton="false"
[copyText]="entityForm.get('pkgName').value"
tooltipText="{{ 'mobile.copy-application-package' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
<mat-hint> </mat-hint>
<mat-error *ngIf="entityForm.get('pkgName').hasError('required')">
{{ 'mobile.mobile-package-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('pkgName').hasError('maxlength')">
{{ 'mobile.mobile-package-max-length' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('pkgName').hasError('pattern')">
{{ 'mobile.mobile-package-spaces' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="flex">
<mat-label translate>mobile.platform-type</mat-label>
<mat-select formControlName="platformType">
<mat-option *ngFor="let platformType of platformTypes" [value]="platformType">
{{ platformTypeTranslations.get(platformType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label translate>mobile.application-secret</mat-label>
<input matInput required formControlName="appSecret"
placeholder="{{ 'mobile.set' | translate }}">
<div matSuffix style="display: flex; align-items: center">
<button mat-icon-button
type="button"
*ngIf="isEdit"
matTooltip="{{ 'mobile.generate-application-secret' | translate }}"
matTooltipPosition="above"
(click)="generateAppSecret($event)">
<tb-icon>cached</tb-icon>
</button>
<tb-copy-button
miniButton="false"
[copyText]="entityForm.get('appSecret').value"
tooltipText="{{ 'mobile.copy-application-secret' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
</div>
<mat-hint> </mat-hint>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label translate>mobile.status</mat-label>
<mat-select formControlName="status">
<mat-option *ngFor="let mobileAppStatus of mobileAppStatuses" [value]="mobileAppStatus">
{{ mobileAppStatusTranslations.get(mobileAppStatus) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<section class="tb-form-panel stroked no-padding-bottom" formGroupName="versionInfo" style="margin-bottom: 21px;">
<div class="tb-form-panel-title" translate>mobile.version-information</div>
<section fxLayout="column">
<mat-form-field appearance="outline">
<mat-label translate>mobile.min-version</mat-label>
<input matInput formControlName="minVersion">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label translate>mobile.latest-version</mat-label>
<input matInput formControlName="latestVersion">
</mat-form-field>
</section>
</section>
<section class="tb-form-panel stroked no-padding-bottom" formGroupName="storeInfo">
<div class="tb-form-panel-title" translate>mobile.store-information</div>
<section fxLayout="column">
<mat-form-field appearance="outline">
<mat-label>{{
(entityForm.get('platformType').value === PlatformType.ANDROID ? 'mobile.google-play-link' : 'mobile.app-store-link') | translate
}}</mat-label>
<input matInput [required]="entityForm.get('status').value === 'PUBLISHED'" formControlName="storeLink">
<tb-copy-button
matSuffix
miniButton="false"
[copyText]="entityForm.get('storeInfo.storeLink').value"
tooltipText="{{ (entityForm.get('platformType').value === PlatformType.ANDROID ? 'mobile.copy-google-play-link' : 'mobile.copy-app-store-link') | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
<mat-error *ngIf="entityForm.get('storeInfo.storeLink').hasError('required')">
{{ (entityForm.get('platformType').value === PlatformType.ANDROID ? 'mobile.google-play-link-required' : 'mobile.app-store-link-required') | translate }}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" *ngIf="entityForm.get('platformType').value === PlatformType.ANDROID">
<mat-label translate>mobile.sha256-certificate-fingerprints</mat-label>
<input matInput [required]="entityForm.get('status').value === MobileAppStatus.PUBLISHED" formControlName="sha256CertFingerprints">
<tb-copy-button
matSuffix
miniButton="false"
[copyText]="entityForm.get('storeInfo.sha256CertFingerprints').value"
tooltipText="{{ 'mobile.copy-sha256-certificate-fingerprints' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
<mat-error *ngIf="entityForm.get('storeInfo.sha256CertFingerprints').hasError('required')">
{{ 'mobile.sha256-certificate-fingerprints-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" *ngIf="entityForm.get('platformType').value === PlatformType.IOS">
<mat-label translate>mobile.app-id</mat-label>
<input matInput [required]="entityForm.get('status').value === MobileAppStatus.PUBLISHED" formControlName="appId">
<tb-copy-button
matSuffix
miniButton="false"
[copyText]="entityForm.get('storeInfo.appId').value"
tooltipText="{{ 'mobile.copy-app-id' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
<mat-error *ngIf="entityForm.get('storeInfo.appId').hasError('required')">
{{ 'mobile.app-id-required' | translate }}
</mat-error>
</mat-form-field>
</section>
</section>
</section>

18
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.scss

@ -0,0 +1,18 @@
/**
* 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 {
--mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12);
}

153
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts

@ -0,0 +1,153 @@
///
/// 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 { 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 { FormBuilder, FormGroup, UntypedFormControl, Validators } from '@angular/forms';
import { randomAlphanumeric } from '@core/utils';
import { EntityType } from '@shared/models/entity-type.models';
import { MobileApp, MobileAppStatus, mobileAppStatusTranslations } from '@shared/models/mobile-app.models';
import { PlatformType, platformTypeTranslations } from '@shared/models/oauth2.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'tb-mobile-app',
templateUrl: './mobile-app.component.html',
styleUrls: ['./mobile-app.component.scss']
})
export class MobileAppComponent extends EntityComponent<MobileApp> {
entityType = EntityType;
platformTypes = [PlatformType.ANDROID, PlatformType.IOS];
MobileAppStatus = MobileAppStatus;
PlatformType = PlatformType;
mobileAppStatuses = Object.keys(MobileAppStatus) as MobileAppStatus[];
platformTypeTranslations = platformTypeTranslations;
mobileAppStatusTranslations = mobileAppStatusTranslations;
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
@Inject('entity') protected entityValue: MobileApp,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<MobileApp>,
protected cd: ChangeDetectorRef,
public fb: FormBuilder) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
}
buildForm(entity: MobileApp): FormGroup {
const form = this.fb.group({
pkgName: [entity?.pkgName ? entity.pkgName : '', [Validators.required, Validators.maxLength(255),
Validators.pattern(/^\S+$/)]],
platformType: [entity?.platformType ? entity.platformType : PlatformType.ANDROID],
appSecret: [entity?.appSecret ? entity.appSecret : btoa(randomAlphanumeric(64)), [Validators.required, this.base64Format]],
status: [entity?.status ? entity.status : MobileAppStatus.DRAFT],
versionInfo: this.fb.group({
minVersion: [entity?.versionInfo?.minVersion ? entity.versionInfo.minVersion : ''],
latestVersion: [entity?.versionInfo?.latestVersion ? entity.versionInfo.latestVersion : ''],
}),
storeInfo: this.fb.group({
storeLink: [entity?.storeInfo?.storeLink ? entity.storeInfo.storeLink : ''],
sha256CertFingerprints: [entity?.storeInfo?.sha256CertFingerprints ? entity.storeInfo.sha256CertFingerprints : ''],
appId: [entity?.storeInfo?.appId ? entity.storeInfo.appId : ''],
}),
});
form.get('platformType').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value: PlatformType) => {
if (value === PlatformType.ANDROID) {
form.get('storeInfo.sha256CertFingerprints').enable({emitEvent: false});
form.get('storeInfo.appId').disable({emitEvent: false});
} else if (value === PlatformType.IOS) {
form.get('storeInfo.sha256CertFingerprints').disable({emitEvent: false});
form.get('storeInfo.appId').enable({emitEvent: false});
}
form.get('storeInfo.storeLink').setValue('', {emitEvent: false});
});
form.get('status').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value: MobileAppStatus) => {
if (value === MobileAppStatus.PUBLISHED) {
form.get('storeInfo.storeLink').addValidators(Validators.required);
form.get('storeInfo.sha256CertFingerprints').addValidators(Validators.required);
form.get('storeInfo.appId').addValidators(Validators.required);
} else {
form.get('storeInfo.storeLink').clearValidators();
form.get('storeInfo.sha256CertFingerprints').clearValidators();
form.get('storeInfo.appId').clearValidators();
}
form.get('storeInfo.storeLink').updateValueAndValidity({emitEvent: false});
form.get('storeInfo.sha256CertFingerprints').updateValueAndValidity({emitEvent: false});
form.get('storeInfo.appId').updateValueAndValidity({emitEvent: false});
});
return form;
}
updateForm(entity: MobileApp) {
this.entityForm.patchValue(entity, {emitEvent: false});
}
override updateFormState(): void {
super.updateFormState();
if (this.isEdit && this.entityForm && !this.isAdd) {
this.entityForm.get('platformType').disable({emitEvent: false});
if (this.entityForm.get('platformType').value === PlatformType.ANDROID) {
this.entityForm.get('storeInfo.appId').disable({emitEvent: false});
} else if (this.entityForm.get('platformType').value === PlatformType.IOS) {
this.entityForm.get('storeInfo.sha256CertFingerprints').disable({emitEvent: false});
}
}
if (this.entityForm && this.isAdd) {
this.entityForm.get('storeInfo.appId').disable({emitEvent: false});
}
}
override prepareFormValue(value: MobileApp): MobileApp {
value.storeInfo = this.entityForm.get('storeInfo').value;
return super.prepareFormValue(value);
}
generateAppSecret($event: Event) {
$event.stopPropagation();
this.entityForm.get('appSecret').setValue(btoa(randomAlphanumeric(64)));
this.entityForm.get('appSecret').markAsDirty();
}
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};
}
}
}

78
ui-ngx/src/app/modules/home/pages/mobile/mobile-routing.module.ts

@ -0,0 +1,78 @@
///
/// 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 { RouterModule, Routes } from '@angular/router';
import { RouterTabsComponent } from '@home/components/router-tabs.component';
import { Authority } from '@shared/models/authority.enum';
import { MenuId } from '@core/services/menu.models';
import { MobileAppTableConfigResolver } from '@home/pages/mobile/applications/mobile-app-table-config.resolver';
import { MobileAppSettingsComponent } from '@home/pages/admin/mobile-app-settings.component';
import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
import { applicationsRoutes } from '@home/pages/mobile/applications/applications-routing.module';
const routes: Routes = [
{
path: 'mobile-center',
component: RouterTabsComponent,
data: {
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
breadcrumb: {
menuId: MenuId.mobile_center
}
},
children: [
{
path: '',
children: [],
data: {
auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER, Authority.SYS_ADMIN],
redirectTo: '/mobile-center/applications'
}
},
...applicationsRoutes,
{
path: 'mobile-app',
component: MobileAppSettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.mobile-app.mobile-app',
breadcrumb: {
menuId: MenuId.mobile_app_settings
}
}
}
]
}
];
routes.push(
{
path: 'security-settings/oauth2/mobile-applications',
pathMatch: 'full',
redirectTo: '/mobile-center/applications'
}
);
@NgModule({
providers: [
MobileAppTableConfigResolver
],
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class MobileRoutingModule { }

33
ui-ngx/src/app/modules/home/pages/mobile/mobile.module.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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module';
import { HomeComponentsModule } from '@home/components/home-components.module';
import { MobileRoutingModule } from '@home/pages/mobile/mobile-routing.module';
import { ApplicationModule } from '@home/pages/mobile/applications/applications.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
HomeComponentsModule,
ApplicationModule,
MobileRoutingModule,
]
})
export class MobileModule { }

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

@ -47,6 +47,7 @@ export enum EntityType {
NOTIFICATION_TEMPLATE = 'NOTIFICATION_TEMPLATE',
OAUTH2_CLIENT = 'OAUTH2_CLIENT',
DOMAIN = 'DOMAIN',
MOBILE_APP_BUNDLE = 'MOBILE_APP_BUNDLE',
MOBILE_APP = 'MOBILE_APP'
}
@ -458,9 +459,9 @@ export const entityTypeTranslations = new Map<EntityType | AliasEntityType, Enti
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'
add: 'mobile.add-application',
noEntities: 'mobile.no=application',
search: 'mobile.search-application'
}
]
]

27
ui-ngx/src/app/shared/models/id/mobile-app-bundle-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 MobileAppBundleId implements EntityId {
entityType = EntityType.MOBILE_APP_BUNDLE;
id: string;
constructor(id: string) {
this.id = id;
}
}

2
ui-ngx/src/app/shared/models/id/public-api.ts

@ -26,6 +26,8 @@ export * from './entity-id';
export * from './entity-view-id';
export * from './event-id';
export * from './has-uuid';
export * from './mobile-app-bundle-id';
export * from './mobile-app-id';
export * from './notification-id';
export * from './notification-request-id';
export * from './notification-rule-id';

95
ui-ngx/src/app/shared/models/mobile-app.models.ts

@ -15,14 +15,22 @@
///
import { HasTenantId } from '@shared/models/entity.models';
import { BaseData } from '@shared/models/base-data';
import { MobileAppId } from '@shared/models/id/mobile-app-id';
import { OAuth2ClientInfo, PlatformType } from '@shared/models/oauth2.models';
import { MobileAppBundleId } from '@shared/models/id/mobile-app-bundle-id';
export interface MobileAppSettings extends HasTenantId {
export interface QrCodeSettings extends HasTenantId {
useDefaultApp: boolean;
androidConfig: AndroidConfig;
iosConfig: IosConfig;
mobileAppBundleId: MobileAppBundleId
androidConfig: AndroidConfig; //TODO: need remove
iosConfig: IosConfig; //TODO: need remove
qrCodeConfig: QRCodeConfig;
defaultGooglePlayLink: string;
defaultAppStoreLink: string;
id: {
id: string;
}
}
export interface AndroidConfig {
@ -60,3 +68,84 @@ export const badgePositionTranslationsMap = new Map<BadgePosition, string>([
[BadgePosition.RIGHT, 'admin.mobile-app.right'],
[BadgePosition.LEFT, 'admin.mobile-app.left']
]);
export type QrCodeConfig = AndroidConfig & IosConfig;
export enum MobileAppStatus {
DRAFT = 'DRAFT',
PUBLISHED = 'PUBLISHED',
DEPRECATED = 'DEPRECATED',
SUSPENDED = 'SUSPENDED'
}
export const mobileAppStatusTranslations = new Map<MobileAppStatus, string>(
[
[MobileAppStatus.DRAFT, 'mobile.status-type.draft'],
[MobileAppStatus.PUBLISHED, 'mobile.status-type.published'],
[MobileAppStatus.DEPRECATED, 'mobile.status-type.deprecated'],
[MobileAppStatus.SUSPENDED, 'mobile.status-type.suspended'],
]
);
export interface VersionInfo {
minVersion: string;
minVersionReleaseNotes?: string;
latestVersion: string;
latestVersionReleaseNotes?: string;
}
export interface StoreInfo {
sha256CertFingerprints?: string;
storeLink: string;
appId?: string;
}
export interface MobileApp extends BaseData<MobileAppId>, HasTenantId {
pkgName: string;
appSecret: string;
platformType: PlatformType;
status: MobileAppStatus;
versionInfo: VersionInfo;
storeInfo: StoreInfo;
}
enum MobileMenuPath {
HOME = 'HOME',
ASSETS = 'ASSETS',
DEVICES = 'DEVICES',
DEVICE_LIST = 'DEVICE_LIST',
ALARMS = 'ALARMS',
DASHBOARDS = 'DASHBOARDS',
DASHBOARD = 'DASHBOARD',
AUDIT_LOGS = 'AUDIT_LOGS',
CUSTOMERS = 'CUSTOMERS',
CUSTOMER = 'CUSTOMER',
NOTIFICATION = 'NOTIFICATION',
CUSTOM = 'CUSTOM'
}
export interface MobileMenuItem {
label: string;
icon: string;
path: MobileMenuPath;
id: string;
}
export interface MobileLayoutConfig {
items: MobileMenuItem[];
}
export interface MobileAppBundle extends Omit<BaseData<MobileAppBundleId>, 'label'>, HasTenantId {
title?: string;
description?: string;
androidAppId?: MobileAppId;
iosAppId?: MobileAppId;
layoutConfig?: MobileLayoutConfig;
oauth2Enabled: boolean;
}
export interface MobileAppBundleInfo extends MobileAppBundle {
androidPkgName: string;
iosPkgName: string;
oauth2ClientInfos?: Array<OAuth2ClientInfo>;
}

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

@ -20,7 +20,6 @@ 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 enum DomainSchema {
HTTP = 'HTTP',
@ -88,17 +87,6 @@ export interface DomainInfo extends Domain, HasOauth2Clients {
oauth2ClientInfos?: Array<OAuth2ClientInfo> | Array<string>;
}
export interface MobileApp extends BaseData<MobileAppId>, HasTenantId {
tenantId?: TenantId;
pkgName: string;
appSecret: string;
oauth2Enabled: boolean;
}
export interface MobileAppInfo extends MobileApp, HasOauth2Clients {
oauth2ClientInfos?: Array<OAuth2ClientInfo> | Array<string>;
}
export interface OAuth2Client extends BaseData<OAuth2ClientId>, HasTenantId {
tenantId?: TenantId;
title: string;

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

@ -322,6 +322,7 @@
"mobile-package-placeholder": "Ex.: my.example.app",
"mobile-package-hint": "For Android: your own unique Application ID. For iOS: Product bundle identifier.",
"mobile-package-unique": "Application package must be unique.",
"mobile-package-required": "Application package is required.",
"mobile-package-max-length": "Application package should be less than 256",
"mobile-package-spaces": "Application package should not contain spaces",
"mobile-app-secret": "Application secret",
@ -4057,6 +4058,50 @@
"copy-code": "Click to copy",
"copied": "Copied!"
},
"mobile": {
"add-application": "Add application",
"app-id": "App ID",
"app-id-required": "App ID is required",
"app-store-link": "App Store link",
"app-store-link-required": "App Store link is required",
"application-details": "Application details",
"application-package": "Application Package",
"application-secret": "Application Secret",
"applications": "Applications",
"copy-app-id": "Copy App ID",
"copy-app-store-link": "Copy App Store link",
"copy-application-package": "Copy application package",
"copy-application-secret": "Copy application secret",
"copy-google-play-link": "Copy Google Play link",
"copy-sha256-certificate-fingerprints": "Copy SHA256 certificate fingerprints",
"delete-applications-text": "Be careful, after the confirmation the mobile application and all related data will become unrecoverable.",
"delete-applications-title": "Are you sure you want to delete the mobile application '{{applicationName}}'?",
"generate-application-secret": "Generate application secret",
"google-play-link": "Google Play link",
"google-play-link-required": "Google Play link is required",
"latest-version": "Latest version",
"min-version": "Min version",
"mobile-center": "Mobile center",
"mobile-package": "Application package",
"mobile-package-max-length": "Application package should be less than 256",
"mobile-package-required": "Application package is required.",
"mobile-package-spaces": "Application package should not contain spaces",
"no=application": "No applications found",
"platform-type": "Platform type",
"search-application": "Search applications",
"set": "Set",
"sha256-certificate-fingerprints": "SHA256 certificate fingerprints",
"sha256-certificate-fingerprints-required": "SHA256 certificate fingerprints is required",
"status": "Status",
"status-type": {
"deprecated": "Deprecated",
"draft": "Draft",
"published": "Published",
"suspended": "Suspended"
},
"store-information": "Store information",
"version-information": "Version information"
},
"notification": {
"action-button": "Action button",
"action-type": "Action type",

Loading…
Cancel
Save