feat(console): custom domain (#1624)

* fix: custom domain

* rem logs

* Update console/src/assets/i18n/de.json

* Update console/src/assets/i18n/en.json

Co-authored-by: Livio Amstutz <livio.a@gmail.com>
This commit is contained in:
Max Peintner 2021-04-20 13:35:18 +02:00 committed by GitHub
parent 8a91c239bb
commit e64af04747
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 252 additions and 223 deletions

View File

@ -112,6 +112,14 @@
[disabled]="(['iam.features.write'] | hasRole | async) == false"> [disabled]="(['iam.features.write'] | hasRole | async) == false">
</mat-slide-toggle> </mat-slide-toggle>
</div> </div>
<div class="row">
<span class="left-desc">{{'FEATURES.DATA.CUSTOMDOMAIN' | translate}}</span>
<span class="fill-space"></span>
<mat-slide-toggle color="primary" name="hasNumber" ngDefaultControl [(ngModel)]="features.customDomain"
[disabled]="(['iam.features.write'] | hasRole | async) == false">
</mat-slide-toggle>
</div>
</div> </div>
<div class="btn-container"> <div class="btn-container">

View File

@ -158,6 +158,7 @@ export class FeaturesComponent implements OnDestroy {
req.setLoginPolicyPasswordless(this.features.loginPolicyPasswordless); req.setLoginPolicyPasswordless(this.features.loginPolicyPasswordless);
req.setPasswordComplexityPolicy(this.features.passwordComplexityPolicy); req.setPasswordComplexityPolicy(this.features.passwordComplexityPolicy);
req.setLabelPolicy(this.features.labelPolicy); req.setLabelPolicy(this.features.labelPolicy);
req.setCustomDomain(this.features.customDomain);
this.adminService.setOrgFeatures(req).then(() => { this.adminService.setOrgFeatures(req).then(() => {
this.toast.showInfo('POLICY.TOAST.SET', true); this.toast.showInfo('POLICY.TOAST.SET', true);
@ -175,6 +176,7 @@ export class FeaturesComponent implements OnDestroy {
dreq.setLoginPolicyPasswordless(this.features.loginPolicyPasswordless); dreq.setLoginPolicyPasswordless(this.features.loginPolicyPasswordless);
dreq.setPasswordComplexityPolicy(this.features.passwordComplexityPolicy); dreq.setPasswordComplexityPolicy(this.features.passwordComplexityPolicy);
dreq.setLabelPolicy(this.features.labelPolicy); dreq.setLabelPolicy(this.features.labelPolicy);
dreq.setCustomDomain(this.features.customDomain);
this.adminService.setDefaultFeatures(dreq).then(() => { this.adminService.setDefaultFeatures(dreq).then(() => {
this.toast.showInfo('POLICY.TOAST.SET', true); this.toast.showInfo('POLICY.TOAST.SET', true);

View File

@ -11,8 +11,12 @@
</button> </button>
<div *ngFor="let domain of domains" class="domain"> <div *ngFor="let domain of domains" class="domain">
<span *ngIf="canwrite$ | async" (click)="verifyDomain(domain)" <span *ngIf="(canwrite$ | async) && (['custom_domain'] | hasFeature | async); else noCustom" (click)="verifyDomain(domain)"
class="title">{{domain.domainName}}</span> class="title">{{domain.domainName}}</span>
<ng-template #noCustom>
<span class="title disabled">{{domain.domainName}}</span>
</ng-template>
<span *ngIf="(canwrite$ | async) == false" class="title disabled">{{domain?.domainName}}</span> <span *ngIf="(canwrite$ | async) == false" class="title disabled">{{domain?.domainName}}</span>
<i matTooltip="verified" *ngIf="domain.isVerified" class="verified las la-check-circle"></i> <i matTooltip="verified" *ngIf="domain.isVerified" class="verified las la-check-circle"></i>
@ -26,7 +30,12 @@
class="las la-trash"></i></button> class="las la-trash"></i></button>
</div> </div>
<p class="new-desc">{{'ORG.PAGES.ORGDOMAIN.VERIFICATION' | translate}}</p> <p class="new-desc">{{'ORG.PAGES.ORGDOMAIN.VERIFICATION' | translate}}</p>
<button [disabled]="(canwrite$ | async) == false" matTooltip="Add domain" mat-raised-button
<cnsl-info-section type="WARN" *ngIf="(['custom_domain'] | hasFeature | async) == false" class="custom-domain-deactivated">
{{'ORG.PAGES.CUSTOMDOMAINFEATUREMISSING' | translate}}
</cnsl-info-section>
<button [disabled]="(canwrite$ | async) == false || (['custom_domain'] | hasFeature | async) == false" matTooltip="Add domain" mat-raised-button
color="primary" (click)="addNewDomain()">{{'ORG.DOMAINS.NEW' | translate}} color="primary" (click)="addNewDomain()">{{'ORG.DOMAINS.NEW' | translate}}
</button> </button>
</app-card> </app-card>

View File

@ -71,11 +71,11 @@ h2 {
.title { .title {
font-size: 16px; font-size: 16px;
margin-right: 1rem; margin-right: 1rem;
cursor: pointer;
&:not(.disabled) { &:not(.disabled) {
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
cursor: pointer;
} }
} }
} }
@ -119,3 +119,7 @@ h2 {
font-size: 14px; font-size: 14px;
color: #818a8a; color: #818a8a;
} }
.custom-domain-deactivated {
margin-bottom: 1rem;
}

View File

@ -22,206 +22,204 @@ import { DomainVerificationComponent } from './domain-verification/domain-verifi
@Component({ @Component({
selector: 'app-org-detail', selector: 'app-org-detail',
templateUrl: './org-detail.component.html', templateUrl: './org-detail.component.html',
styleUrls: ['./org-detail.component.scss'], styleUrls: ['./org-detail.component.scss'],
}) })
export class OrgDetailComponent implements OnInit { export class OrgDetailComponent implements OnInit {
public org!: Org.AsObject; public org!: Org.AsObject;
public PolicyComponentServiceType: any = PolicyComponentServiceType; public PolicyComponentServiceType: any = PolicyComponentServiceType;
public OrgState: any = OrgState; public OrgState: any = OrgState;
public ChangeType: any = ChangeType; public ChangeType: any = ChangeType;
public domains: Domain.AsObject[] = []; public domains: Domain.AsObject[] = [];
public primaryDomain: string = ''; public primaryDomain: string = '';
// members // members
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false); private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable(); public loading$: Observable<boolean> = this.loadingSubject.asObservable();
public totalMemberResult: number = 0; public totalMemberResult: number = 0;
public membersSubject: BehaviorSubject<Member.AsObject[]> public membersSubject: BehaviorSubject<Member.AsObject[]>
= new BehaviorSubject<Member.AsObject[]>([]); = new BehaviorSubject<Member.AsObject[]>([]);
public PolicyGridType: any = PolicyGridType; public PolicyGridType: any = PolicyGridType;
public features!: Features.AsObject; public features!: Features.AsObject;
constructor( constructor(
private dialog: MatDialog, private dialog: MatDialog,
public translate: TranslateService, public translate: TranslateService,
public mgmtService: ManagementService, public mgmtService: ManagementService,
private toast: ToastService, private toast: ToastService,
private router: Router, private router: Router,
) { ) { }
public ngOnInit(): void {
this.getData();
}
private async getData(): Promise<void> {
this.mgmtService.getMyOrg().then((resp) => {
if (resp.org) {
this.org = resp.org;
}
}).catch(error => {
this.toast.showError(error);
});
this.loadMembers();
this.loadDomains();
this.loadFeatures();
}
public loadDomains(): void {
this.mgmtService.listOrgDomains().then(result => {
this.domains = result.resultList;
this.primaryDomain = this.domains.find(domain => domain.isPrimary)?.domainName ?? '';
});
}
public setPrimary(domain: Domain.AsObject): void {
this.mgmtService.setPrimaryOrgDomain(domain.domainName).then(() => {
this.toast.showInfo('ORG.TOAST.SETPRIMARY', true);
this.loadDomains();
}).catch((error) => {
this.toast.showError(error);
});
}
public changeState(event: MatButtonToggleChange | any): void {
if (event.value === OrgState.ORG_STATE_ACTIVE) {
this.mgmtService.reactivateOrg().then(() => {
this.toast.showInfo('ORG.TOAST.REACTIVATED', true);
}).catch((error) => {
this.toast.showError(error);
});
} else if (event.value === OrgState.ORG_STATE_INACTIVE) {
this.mgmtService.deactivateOrg().then(() => {
this.toast.showInfo('ORG.TOAST.DEACTIVATED', true);
}).catch((error) => {
this.toast.showError(error);
});
} }
}
public ngOnInit(): void { public addNewDomain(): void {
this.getData(); const dialogRef = this.dialog.open(AddDomainDialogComponent, {
} data: {},
width: '400px',
});
private async getData(): Promise<void> { dialogRef.afterClosed().subscribe(resp => {
this.mgmtService.getMyOrg().then((resp) => { if (resp) {
if (resp.org) { this.mgmtService.addOrgDomain(resp).then(() => {
this.org = resp.org; this.toast.showInfo('ORG.TOAST.DOMAINADDED', true);
}
}).catch(error => {
this.toast.showError(error);
});
this.loadMembers();
this.loadDomains();
this.loadFeatures();
}
public loadDomains(): void { setTimeout(() => {
this.mgmtService.listOrgDomains().then(result => {
this.domains = result.resultList;
this.primaryDomain = this.domains.find(domain => domain.isPrimary)?.domainName ?? '';
});
}
public setPrimary(domain: Domain.AsObject): void {
this.mgmtService.setPrimaryOrgDomain(domain.domainName).then(() => {
this.toast.showInfo('ORG.TOAST.SETPRIMARY', true);
this.loadDomains(); this.loadDomains();
}).catch((error) => { }, 1000);
}).catch(error => {
this.toast.showError(error);
});
}
});
}
public removeDomain(domain: string): void {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.DELETE',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'ORG.DOMAINS.DELETE.TITLE',
descriptionKey: 'ORG.DOMAINS.DELETE.DESCRIPTION',
},
width: '400px',
});
dialogRef.afterClosed().subscribe(resp => {
if (resp) {
this.mgmtService.removeOrgDomain(domain).then(() => {
this.toast.showInfo('ORG.TOAST.DOMAINREMOVED', true);
const index = this.domains.findIndex(d => d.domainName === domain);
if (index > -1) {
this.domains.splice(index, 1);
}
}).catch(error => {
this.toast.showError(error);
});
}
});
}
public openAddMember(): void {
const dialogRef = this.dialog.open(MemberCreateDialogComponent, {
data: {
creationType: CreationType.ORG,
},
width: '400px',
});
dialogRef.afterClosed().subscribe(resp => {
if (resp) {
const users: User.AsObject[] = resp.users;
const roles: string[] = resp.roles;
if (users && users.length && roles && roles.length) {
Promise.all(users.map(user => {
return this.mgmtService.addOrgMember(user.id, roles);
})).then(() => {
this.toast.showInfo('ORG.TOAST.MEMBERADDED', true);
setTimeout(() => {
this.loadMembers();
}, 1000);
}).catch(error => {
this.toast.showError(error); this.toast.showError(error);
}); });
}
public changeState(event: MatButtonToggleChange | any): void {
if (event.value === OrgState.ORG_STATE_ACTIVE) {
this.mgmtService.reactivateOrg().then(() => {
this.toast.showInfo('ORG.TOAST.REACTIVATED', true);
}).catch((error) => {
this.toast.showError(error);
});
} else if (event.value === OrgState.ORG_STATE_INACTIVE) {
this.mgmtService.deactivateOrg().then(() => {
this.toast.showInfo('ORG.TOAST.DEACTIVATED', true);
}).catch((error) => {
this.toast.showError(error);
});
} }
} }
});
}
public addNewDomain(): void { public showDetail(): void {
const dialogRef = this.dialog.open(AddDomainDialogComponent, { this.router.navigate(['org/members']);
data: {}, }
width: '400px',
});
dialogRef.afterClosed().subscribe(resp => { public verifyDomain(domain: Domain.AsObject): void {
if (resp) { const dialogRef = this.dialog.open(DomainVerificationComponent, {
this.mgmtService.addOrgDomain(resp).then(() => { data: {
this.toast.showInfo('ORG.TOAST.DOMAINADDED', true); domain: domain,
},
width: '500px',
});
setTimeout(() => { dialogRef.afterClosed().subscribe((reload) => {
this.loadDomains(); if (reload) {
}, 1000); this.loadDomains();
}).catch(error => { }
this.toast.showError(error); });
}); }
}
});
}
public removeDomain(domain: string): void { public loadMembers(): void {
const dialogRef = this.dialog.open(WarnDialogComponent, { this.loadingSubject.next(true);
data: { from(this.mgmtService.listOrgMembers(100, 0)).pipe(
confirmKey: 'ACTIONS.DELETE', map(resp => {
cancelKey: 'ACTIONS.CANCEL', if (resp.details?.totalResult) {
titleKey: 'ORG.DOMAINS.DELETE.TITLE', this.totalMemberResult = resp.details?.totalResult;
descriptionKey: 'ORG.DOMAINS.DELETE.DESCRIPTION', }
}, return resp.resultList;
width: '400px', }),
}); catchError(() => of([])),
finalize(() => this.loadingSubject.next(false)),
).subscribe(members => {
this.membersSubject.next(members);
});
}
dialogRef.afterClosed().subscribe(resp => { public loadFeatures(): void {
if (resp) { this.loadingSubject.next(true);
this.mgmtService.removeOrgDomain(domain).then(() => { this.mgmtService.getFeatures().then(resp => {
this.toast.showInfo('ORG.TOAST.DOMAINREMOVED', true); if (resp.features) {
const index = this.domains.findIndex(d => d.domainName === domain); this.features = resp.features;
if (index > -1) { }
this.domains.splice(index, 1); });
} }
}).catch(error => {
this.toast.showError(error);
});
}
});
}
public openAddMember(): void {
const dialogRef = this.dialog.open(MemberCreateDialogComponent, {
data: {
creationType: CreationType.ORG,
},
width: '400px',
});
dialogRef.afterClosed().subscribe(resp => {
if (resp) {
const users: User.AsObject[] = resp.users;
const roles: string[] = resp.roles;
if (users && users.length && roles && roles.length) {
Promise.all(users.map(user => {
return this.mgmtService.addOrgMember(user.id, roles);
})).then(() => {
this.toast.showInfo('ORG.TOAST.MEMBERADDED', true);
setTimeout(() => {
this.loadMembers();
}, 1000);
}).catch(error => {
this.toast.showError(error);
});
}
}
});
}
public showDetail(): void {
this.router.navigate(['org/members']);
}
public verifyDomain(domain: Domain.AsObject): void {
const dialogRef = this.dialog.open(DomainVerificationComponent, {
data: {
domain: domain,
},
width: '500px',
});
dialogRef.afterClosed().subscribe((reload) => {
if (reload) {
this.loadDomains();
}
});
}
public loadMembers(): void {
this.loadingSubject.next(true);
from(this.mgmtService.listOrgMembers(100, 0)).pipe(
map(resp => {
if (resp.details?.totalResult) {
this.totalMemberResult = resp.details?.totalResult;
}
return resp.resultList;
}),
catchError(() => of([])),
finalize(() => this.loadingSubject.next(false)),
).subscribe(members => {
this.membersSubject.next(members);
});
}
public loadFeatures(): void {
this.loadingSubject.next(true);
this.mgmtService.getFeatures().then(resp => {
if (resp.features) {
this.features = resp.features;
}
});
}
} }

View File

@ -16,11 +16,13 @@ import { MemberCreateDialogModule } from 'src/app/modules/add-member-dialog/memb
import { CardModule } from 'src/app/modules/card/card.module'; import { CardModule } from 'src/app/modules/card/card.module';
import { ContributorsModule } from 'src/app/modules/contributors/contributors.module'; import { ContributorsModule } from 'src/app/modules/contributors/contributors.module';
import { FeaturesModule } from 'src/app/modules/features/features.module'; import { FeaturesModule } from 'src/app/modules/features/features.module';
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
import { InputModule } from 'src/app/modules/input/input.module'; import { InputModule } from 'src/app/modules/input/input.module';
import { MetaLayoutModule } from 'src/app/modules/meta-layout/meta-layout.module'; import { MetaLayoutModule } from 'src/app/modules/meta-layout/meta-layout.module';
import { PolicyGridModule } from 'src/app/modules/policy-grid/policy-grid.module'; import { PolicyGridModule } from 'src/app/modules/policy-grid/policy-grid.module';
import { SharedModule } from 'src/app/modules/shared/shared.module'; import { SharedModule } from 'src/app/modules/shared/shared.module';
import { WarnDialogModule } from 'src/app/modules/warn-dialog/warn-dialog.module'; import { WarnDialogModule } from 'src/app/modules/warn-dialog/warn-dialog.module';
import { HasFeaturePipeModule } from 'src/app/pipes/has-feature-pipe/has-feature-pipe.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module'; import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { ChangesModule } from '../../modules/changes/changes.module'; import { ChangesModule } from '../../modules/changes/changes.module';
@ -30,35 +32,37 @@ import { OrgDetailComponent } from './org-detail/org-detail.component';
import { OrgsRoutingModule } from './orgs-routing.module'; import { OrgsRoutingModule } from './orgs-routing.module';
@NgModule({ @NgModule({
declarations: [OrgDetailComponent, DomainVerificationComponent], declarations: [OrgDetailComponent, DomainVerificationComponent],
imports: [ imports: [
CommonModule, CommonModule,
HasRolePipeModule, HasRolePipeModule,
OrgsRoutingModule, OrgsRoutingModule,
FormsModule, FormsModule,
HasRoleModule, HasRoleModule,
InputModule, InputModule,
MatButtonModule, InfoSectionModule,
MatDialogModule, MatButtonModule,
CardModule, MatDialogModule,
MatIconModule, CardModule,
ReactiveFormsModule, MatIconModule,
MatButtonToggleModule, ReactiveFormsModule,
MetaLayoutModule, MatButtonToggleModule,
MatTabsModule, MetaLayoutModule,
MatTooltipModule, MatTabsModule,
WarnDialogModule, MatTooltipModule,
MemberCreateDialogModule, WarnDialogModule,
MatMenuModule, MemberCreateDialogModule,
ChangesModule, HasFeaturePipeModule,
MatProgressSpinnerModule, MatMenuModule,
AddDomainDialogModule, ChangesModule,
TranslateModule, MatProgressSpinnerModule,
SharedModule, AddDomainDialogModule,
ContributorsModule, TranslateModule,
CopyToClipboardModule, SharedModule,
PolicyGridModule, ContributorsModule,
FeaturesModule, CopyToClipboardModule,
], PolicyGridModule,
FeaturesModule,
],
}) })
export class OrgsModule { } export class OrgsModule { }

View File

@ -1,8 +1,8 @@
{ {
"authServiceUrl": "https://api.zitadel.io", "authServiceUrl": "https://api.zitadel.dev",
"mgmtServiceUrl": "https://api.zitadel.io", "mgmtServiceUrl": "https://api.zitadel.dev",
"adminServiceUrl":"https://api.zitadel.io", "adminServiceUrl":"https://api.zitadel.dev",
"subscriptionServiceUrl":"https://sub.zitadel.io", "subscriptionServiceUrl":"https://sub.zitadel.dev",
"issuer": "https://issuer.zitadel.io", "issuer": "https://issuer.zitadel.dev",
"clientid": "69234247558357051@zitadel" "clientid": "70669160379706195@zitadel"
} }

View File

@ -525,6 +525,7 @@
"ORGDETAIL_TITLE": "Gebe den Namen und die Domain für die neue Organisation ein.", "ORGDETAIL_TITLE": "Gebe den Namen und die Domain für die neue Organisation ein.",
"ORGDETAIL_TITLE_WITHOUT_DOMAIN": "Geben Sie den Namen der neuen Organisation ein.", "ORGDETAIL_TITLE_WITHOUT_DOMAIN": "Geben Sie den Namen der neuen Organisation ein.",
"ORGDETAILUSER_TITLE": "Organisationsbesitzer hinzufügen", "ORGDETAILUSER_TITLE": "Organisationsbesitzer hinzufügen",
"CUSTOMDOMAINFEATUREMISSING":"Das Feature custom-domain ist auf Ihrer Organisation nicht freigeschaltet!",
"ORGDOMAIN": { "ORGDOMAIN": {
"TITLE": "Verifikation der Domain der Organisation", "TITLE": "Verifikation der Domain der Organisation",
"VERIFICATION": "Überprüfe den Besitz Deiner Domain, indem Du eine Bestätigungsdatei herunterlädst und unter der angegebenen URL speicherst, oder indem Du sie mit einem DNS-Eintrag verifizierst.", "VERIFICATION": "Überprüfe den Besitz Deiner Domain, indem Du eine Bestätigungsdatei herunterlädst und unter der angegebenen URL speicherst, oder indem Du sie mit einem DNS-Eintrag verifizierst.",
@ -609,7 +610,8 @@
"LOGINPOLICYFACTORS": "Login Richtlinie: Mltifaktoren - benutzerdefiniert", "LOGINPOLICYFACTORS": "Login Richtlinie: Mltifaktoren - benutzerdefiniert",
"LOGINPOLICYPASSWORDLESS": "Login Richtlinie: Passwortlose Authentifizierung - benutzerdefiniert", "LOGINPOLICYPASSWORDLESS": "Login Richtlinie: Passwortlose Authentifizierung - benutzerdefiniert",
"LOGINPOLICYCOMPLEXITYPOLICY": "Passwortkomplexitäts Richtlinie - benutzerdefiniert", "LOGINPOLICYCOMPLEXITYPOLICY": "Passwortkomplexitäts Richtlinie - benutzerdefiniert",
"LABELPOLICY": "Label Richtlinie - benutzerdefiniert" "LABELPOLICY": "Label Richtlinie - benutzerdefiniert",
"CUSTOMDOMAIN": "Domänen Verifikation - verfügbar"
}, },
"TIERSTATES": { "TIERSTATES": {
"0": "Aktiv", "0": "Aktiv",

View File

@ -525,6 +525,7 @@
"ORGDETAIL_TITLE": "Enter the name and domain of your new organisation.", "ORGDETAIL_TITLE": "Enter the name and domain of your new organisation.",
"ORGDETAIL_TITLE_WITHOUT_DOMAIN": "Enter the name of your new organisation.", "ORGDETAIL_TITLE_WITHOUT_DOMAIN": "Enter the name of your new organisation.",
"ORGDETAILUSER_TITLE": "Configure Organisation Owner", "ORGDETAILUSER_TITLE": "Configure Organisation Owner",
"CUSTOMDOMAINFEATUREMISSING":"The Feature custom-domain is not active on your organization!",
"ORGDOMAIN": { "ORGDOMAIN": {
"TITLE": "Organisation Domain Ownership Verification", "TITLE": "Organisation Domain Ownership Verification",
"VERIFICATION": "Verify the ownership of your domain. You need to download a verification file and upload it at the provided URL listed below, or place a TXT Record DNS entry for the provided URL. To complete, click the button to verify.", "VERIFICATION": "Verify the ownership of your domain. You need to download a verification file and upload it at the provided URL listed below, or place a TXT Record DNS entry for the provided URL. To complete, click the button to verify.",
@ -609,7 +610,8 @@
"LOGINPOLICYFACTORS": "Login Policy: Multifactors - custom", "LOGINPOLICYFACTORS": "Login Policy: Multifactors - custom",
"LOGINPOLICYPASSWORDLESS": "Login Policy: Passwordless Authentication - custom", "LOGINPOLICYPASSWORDLESS": "Login Policy: Passwordless Authentication - custom",
"LOGINPOLICYCOMPLEXITYPOLICY": "Password Complexity Policy - custom", "LOGINPOLICYCOMPLEXITYPOLICY": "Password Complexity Policy - custom",
"LABELPOLICY": "Labeling Policy - custom" "LABELPOLICY": "Labeling Policy - custom",
"CUSTOMDOMAIN": "Domain Verification - available"
}, },
"TIERSTATES": { "TIERSTATES": {
"0": "Active", "0": "Active",