mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-28 12:45:45 +01:00
[SM-352] Projects tab for service accounts (#4858)
* Init service layer changes * refactor service to inherit abstract * refactor access-selector component * update access selector in projects * add service accounts access selector * update i18n * fix delete action; use useExisting in providers * update static permissions * service account people should be readwrite on creation * use setter instead of observable input * remove warning callout * remove abstract service * truncate name in table * remove extra comments * Add projects access policy page * Add locale * use map instead of forEach * refactor view factories * update SA people copy * map list responses * Swap to using granted policies endpoints * Remove text-xl from icon --------- Co-authored-by: Thomas Avery <tavery@bitwarden.com> Co-authored-by: William Martin <contact@willmartian.com> Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
This commit is contained in:
parent
4e112573f5
commit
6348269a1a
@ -5864,7 +5864,7 @@
|
||||
"message": "Service account name"
|
||||
},
|
||||
"newSaSelectAccess":{
|
||||
"message": "Type or select projects or secrets"
|
||||
"message": "Type or select projects"
|
||||
},
|
||||
"newSaTypeToFilter":{
|
||||
"message": "Type to filter"
|
||||
@ -6365,6 +6365,12 @@
|
||||
"serviceAccountPeopleDescription": {
|
||||
"message": "Grant groups or people access to this service account."
|
||||
},
|
||||
"serviceAccountProjectsDescription": {
|
||||
"message": "Assign projects to this service account. "
|
||||
},
|
||||
"serviceAccountEmptyProjectAccesspolicies": {
|
||||
"message": "Add projects to grant access"
|
||||
},
|
||||
"canWrite": {
|
||||
"message": "Can write"
|
||||
},
|
||||
|
@ -34,6 +34,7 @@ export class ServiceAccountProjectAccessPolicyView extends BaseAccessPolicyView
|
||||
serviceAccountId: string;
|
||||
serviceAccountName: string;
|
||||
grantedProjectId: string;
|
||||
grantedProjectName: string;
|
||||
}
|
||||
|
||||
export class ProjectAccessPoliciesView {
|
||||
|
@ -36,7 +36,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
|
||||
rows.push({
|
||||
type: "user",
|
||||
name: policy.organizationUserName,
|
||||
granteeId: policy.organizationUserId,
|
||||
id: policy.organizationUserId,
|
||||
accessPolicyId: policy.id,
|
||||
read: policy.read,
|
||||
write: policy.write,
|
||||
@ -48,7 +48,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
|
||||
rows.push({
|
||||
type: "group",
|
||||
name: policy.groupName,
|
||||
granteeId: policy.groupId,
|
||||
id: policy.groupId,
|
||||
accessPolicyId: policy.id,
|
||||
read: policy.read,
|
||||
write: policy.write,
|
||||
|
@ -33,7 +33,7 @@ export class ProjectServiceAccountsComponent implements OnInit, OnDestroy {
|
||||
policies.serviceAccountAccessPolicies.map((policy) => ({
|
||||
type: "serviceAccount",
|
||||
name: policy.serviceAccountName,
|
||||
granteeId: policy.serviceAccountId,
|
||||
id: policy.serviceAccountId,
|
||||
accessPolicyId: policy.id,
|
||||
read: policy.read,
|
||||
write: policy.write,
|
||||
|
@ -36,7 +36,7 @@ export class ServiceAccountPeopleComponent {
|
||||
rows.push({
|
||||
type: "user",
|
||||
name: policy.organizationUserName,
|
||||
granteeId: policy.organizationUserId,
|
||||
id: policy.organizationUserId,
|
||||
accessPolicyId: policy.id,
|
||||
read: policy.read,
|
||||
write: policy.write,
|
||||
@ -49,7 +49,7 @@ export class ServiceAccountPeopleComponent {
|
||||
rows.push({
|
||||
type: "group",
|
||||
name: policy.groupName,
|
||||
granteeId: policy.groupId,
|
||||
id: policy.groupId,
|
||||
accessPolicyId: policy.id,
|
||||
read: policy.read,
|
||||
write: policy.write,
|
||||
|
@ -0,0 +1,15 @@
|
||||
<div class="tw-mt-4 tw-w-2/5">
|
||||
<p class="tw-mt-6">
|
||||
{{ "serviceAccountProjectsDescription" | i18n }}
|
||||
</p>
|
||||
<sm-access-selector
|
||||
[rows]="rows$ | async"
|
||||
granteeType="projects"
|
||||
[label]="'projects' | i18n"
|
||||
[hint]="'newSaSelectAccess' | i18n"
|
||||
[columnTitle]="'projects' | i18n"
|
||||
[emptyMessage]="'serviceAccountEmptyProjectAccesspolicies' | i18n"
|
||||
(onCreateAccessPolicies)="handleCreateAccessPolicies($event)"
|
||||
>
|
||||
</sm-access-selector>
|
||||
</div>
|
@ -0,0 +1,78 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { combineLatestWith, map, Observable, startWith, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
|
||||
|
||||
import { ServiceAccountProjectAccessPolicyView } from "../../models/view/access-policy.view";
|
||||
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
|
||||
import {
|
||||
AccessSelectorComponent,
|
||||
AccessSelectorRowView,
|
||||
} from "../../shared/access-policies/access-selector.component";
|
||||
|
||||
@Component({
|
||||
selector: "sm-service-account-projects",
|
||||
templateUrl: "./service-account-projects.component.html",
|
||||
})
|
||||
export class ServiceAccountProjectsComponent {
|
||||
private destroy$ = new Subject<void>();
|
||||
private serviceAccountId: string;
|
||||
private organizationId: string;
|
||||
|
||||
protected rows$: Observable<AccessSelectorRowView[]> =
|
||||
this.accessPolicyService.serviceAccountGrantedPolicyChanges$.pipe(
|
||||
startWith(null),
|
||||
combineLatestWith(this.route.params),
|
||||
switchMap(([_, params]) =>
|
||||
this.accessPolicyService.getGrantedPolicies(params.serviceAccountId, params.organizationId)
|
||||
),
|
||||
map((policies) => {
|
||||
return policies.map((policy) => {
|
||||
return {
|
||||
type: "project",
|
||||
name: policy.grantedProjectName,
|
||||
id: policy.grantedProjectId,
|
||||
accessPolicyId: policy.id,
|
||||
read: policy.read,
|
||||
write: policy.write,
|
||||
icon: AccessSelectorComponent.projectIcon,
|
||||
static: true,
|
||||
} as AccessSelectorRowView;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
protected handleCreateAccessPolicies(selected: SelectItemView[]) {
|
||||
const serviceAccountProjectAccessPolicyView = selected
|
||||
.filter((selection) => AccessSelectorComponent.getAccessItemType(selection) === "project")
|
||||
.map((filtered) => {
|
||||
const view = new ServiceAccountProjectAccessPolicyView();
|
||||
view.serviceAccountId = this.serviceAccountId;
|
||||
view.grantedProjectId = filtered.id;
|
||||
view.read = true;
|
||||
view.write = false;
|
||||
return view;
|
||||
});
|
||||
|
||||
return this.accessPolicyService.createGrantedPolicies(
|
||||
this.organizationId,
|
||||
this.serviceAccountId,
|
||||
serviceAccountProjectAccessPolicyView
|
||||
);
|
||||
}
|
||||
|
||||
constructor(private route: ActivatedRoute, private accessPolicyService: AccessPolicyService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params) => {
|
||||
this.organizationId = params.organizationId;
|
||||
this.serviceAccountId = params.serviceAccountId;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
@ -10,7 +10,7 @@
|
||||
</bit-breadcrumbs>
|
||||
<sm-new-menu></sm-new-menu>
|
||||
<bit-tab-nav-bar label="Main" slot="tabs">
|
||||
<bit-tab-link [route]="['secrets']">{{ "secrets" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link [route]="['projects']">{{ "projects" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link [route]="['people']">{{ "people" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link [route]="['access']">{{ "accessTokens" | i18n }}</bit-tab-link>
|
||||
</bit-tab-nav-bar>
|
||||
|
@ -3,6 +3,7 @@ import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { AccessTokenComponent } from "./access/access-tokens.component";
|
||||
import { ServiceAccountPeopleComponent } from "./people/service-account-people.component";
|
||||
import { ServiceAccountProjectsComponent } from "./projects/service-account-projects.component";
|
||||
import { ServiceAccountComponent } from "./service-account.component";
|
||||
import { ServiceAccountsComponent } from "./service-accounts.component";
|
||||
|
||||
@ -18,7 +19,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "",
|
||||
pathMatch: "full",
|
||||
redirectTo: "access",
|
||||
redirectTo: "projects",
|
||||
},
|
||||
{
|
||||
path: "access",
|
||||
@ -28,6 +29,10 @@ const routes: Routes = [
|
||||
path: "people",
|
||||
component: ServiceAccountPeopleComponent,
|
||||
},
|
||||
{
|
||||
path: "projects",
|
||||
component: ServiceAccountProjectsComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@ -11,6 +11,7 @@ import { AccessTokenDialogComponent } from "./access/dialogs/access-token-dialog
|
||||
import { ExpirationOptionsComponent } from "./access/dialogs/expiration-options.component";
|
||||
import { ServiceAccountDialogComponent } from "./dialog/service-account-dialog.component";
|
||||
import { ServiceAccountPeopleComponent } from "./people/service-account-people.component";
|
||||
import { ServiceAccountProjectsComponent } from "./projects/service-account-projects.component";
|
||||
import { ServiceAccountComponent } from "./service-account.component";
|
||||
import { ServiceAccountsListComponent } from "./service-accounts-list.component";
|
||||
import { ServiceAccountsRoutingModule } from "./service-accounts-routing.module";
|
||||
@ -20,12 +21,14 @@ import { ServiceAccountsComponent } from "./service-accounts.component";
|
||||
imports: [SecretsManagerSharedModule, ServiceAccountsRoutingModule, BreadcrumbsModule],
|
||||
declarations: [
|
||||
AccessListComponent,
|
||||
ExpirationOptionsComponent,
|
||||
AccessTokenComponent,
|
||||
AccessTokenCreateDialogComponent,
|
||||
AccessTokenDialogComponent,
|
||||
ExpirationOptionsComponent,
|
||||
ServiceAccountComponent,
|
||||
ServiceAccountDialogComponent,
|
||||
ServiceAccountPeopleComponent,
|
||||
ServiceAccountProjectsComponent,
|
||||
ServiceAccountsComponent,
|
||||
ServiceAccountsListComponent,
|
||||
ServiceAccountPeopleComponent,
|
||||
|
@ -25,6 +25,7 @@ import { ServiceAccountAccessPoliciesResponse } from "../../shared/access-polici
|
||||
|
||||
import { AccessPolicyUpdateRequest } from "./models/requests/access-policy-update.request";
|
||||
import { AccessPolicyRequest } from "./models/requests/access-policy.request";
|
||||
import { GrantedPolicyRequest } from "./models/requests/granted-policy.request";
|
||||
import {
|
||||
GroupServiceAccountAccessPolicyResponse,
|
||||
UserServiceAccountAccessPolicyResponse,
|
||||
@ -40,6 +41,9 @@ import { PotentialGranteeResponse } from "./models/responses/potential-grantee.r
|
||||
export class AccessPolicyService {
|
||||
private _projectAccessPolicyChanges$ = new Subject<ProjectAccessPoliciesView>();
|
||||
private _serviceAccountAccessPolicyChanges$ = new Subject<ServiceAccountAccessPoliciesView>();
|
||||
private _serviceAccountGrantedPolicyChanges$ = new Subject<
|
||||
ServiceAccountProjectAccessPolicyView[]
|
||||
>();
|
||||
|
||||
/**
|
||||
* Emits when a project access policy is created or deleted.
|
||||
@ -52,12 +56,56 @@ export class AccessPolicyService {
|
||||
readonly serviceAccountAccessPolicyChanges$ =
|
||||
this._serviceAccountAccessPolicyChanges$.asObservable();
|
||||
|
||||
/**
|
||||
* Emits when a service account granted policy is created or deleted.
|
||||
*/
|
||||
readonly serviceAccountGrantedPolicyChanges$ =
|
||||
this._serviceAccountGrantedPolicyChanges$.asObservable();
|
||||
|
||||
constructor(
|
||||
private cryptoService: CryptoService,
|
||||
protected apiService: ApiService,
|
||||
protected encryptService: EncryptService
|
||||
) {}
|
||||
|
||||
async getGrantedPolicies(
|
||||
serviceAccountId: string,
|
||||
organizationId: string
|
||||
): Promise<ServiceAccountProjectAccessPolicyView[]> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/service-accounts/" + serviceAccountId + "/granted-policies",
|
||||
null,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
const results = new ListResponse(r, ServiceAccountProjectAccessPolicyResponse);
|
||||
return await this.createServiceAccountProjectAccessPolicyViews(results.data, organizationId);
|
||||
}
|
||||
|
||||
async createGrantedPolicies(
|
||||
organizationId: string,
|
||||
serviceAccountId: string,
|
||||
policies: ServiceAccountProjectAccessPolicyView[]
|
||||
): Promise<ServiceAccountProjectAccessPolicyView[]> {
|
||||
const request = this.getGrantedPoliciesCreateRequest(policies);
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
"/service-accounts/" + serviceAccountId + "/granted-policies",
|
||||
request,
|
||||
true,
|
||||
true
|
||||
);
|
||||
const results = new ListResponse(r, ServiceAccountProjectAccessPolicyResponse);
|
||||
const views = await this.createServiceAccountProjectAccessPolicyViews(
|
||||
results.data,
|
||||
organizationId
|
||||
);
|
||||
this._serviceAccountGrantedPolicyChanges$.next(views);
|
||||
return views;
|
||||
}
|
||||
|
||||
async getProjectAccessPolicies(
|
||||
organizationId: string,
|
||||
projectId: string
|
||||
@ -132,6 +180,7 @@ export class AccessPolicyService {
|
||||
await this.apiService.send("DELETE", "/access-policies/" + accessPolicyId, null, true, false);
|
||||
this._projectAccessPolicyChanges$.next(null);
|
||||
this._serviceAccountAccessPolicyChanges$.next(null);
|
||||
this._serviceAccountGrantedPolicyChanges$.next(null);
|
||||
}
|
||||
|
||||
async updateAccessPolicy(baseAccessPolicyView: BaseAccessPolicyView): Promise<void> {
|
||||
@ -228,6 +277,10 @@ export class AccessPolicyService {
|
||||
...this.createBaseAccessPolicyView(response),
|
||||
grantedProjectId: response.grantedProjectId,
|
||||
serviceAccountId: response.serviceAccountId,
|
||||
grantedProjectName: await this.encryptService.decryptToUtf8(
|
||||
new EncString(response.grantedProjectName),
|
||||
organizationKey
|
||||
),
|
||||
serviceAccountName: await this.encryptService.decryptToUtf8(
|
||||
new EncString(response.serviceAccountName),
|
||||
organizationKey
|
||||
@ -318,6 +371,18 @@ export class AccessPolicyService {
|
||||
return await this.createPotentialGranteeViews(organizationId, results.data);
|
||||
}
|
||||
|
||||
async getProjectsPotentialGrantees(organizationId: string) {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/access-policies/projects/potential-grantees",
|
||||
null,
|
||||
true,
|
||||
true
|
||||
);
|
||||
const results = new ListResponse(r, PotentialGranteeResponse);
|
||||
return await this.createPotentialGranteeViews(organizationId, results.data);
|
||||
}
|
||||
|
||||
protected async getOrganizationKey(organizationId: string): Promise<SymmetricCryptoKey> {
|
||||
return await this.cryptoService.getOrgKey(organizationId);
|
||||
}
|
||||
@ -367,7 +432,7 @@ export class AccessPolicyService {
|
||||
view.type = r.type;
|
||||
view.email = r.email;
|
||||
|
||||
if (r.type === "serviceAccount") {
|
||||
if (r.type === "serviceAccount" || r.type === "project") {
|
||||
view.name = await this.encryptService.decryptToUtf8(new EncString(r.name), orgKey);
|
||||
} else {
|
||||
view.name = r.name;
|
||||
@ -376,4 +441,44 @@ export class AccessPolicyService {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private getGrantedPoliciesCreateRequest(
|
||||
policies: ServiceAccountProjectAccessPolicyView[]
|
||||
): GrantedPolicyRequest[] {
|
||||
return policies.map((ap) => {
|
||||
const request = new GrantedPolicyRequest();
|
||||
request.grantedId = ap.grantedProjectId;
|
||||
request.read = ap.read;
|
||||
request.write = ap.write;
|
||||
return request;
|
||||
});
|
||||
}
|
||||
|
||||
private async createServiceAccountProjectAccessPolicyViews(
|
||||
responses: ServiceAccountProjectAccessPolicyResponse[],
|
||||
organizationId: string
|
||||
): Promise<ServiceAccountProjectAccessPolicyView[]> {
|
||||
const orgKey = await this.getOrganizationKey(organizationId);
|
||||
return await Promise.all(
|
||||
responses.map(async (response: ServiceAccountProjectAccessPolicyResponse) => {
|
||||
const view = new ServiceAccountProjectAccessPolicyView();
|
||||
view.id = response.id;
|
||||
view.read = response.read;
|
||||
view.write = response.write;
|
||||
view.creationDate = response.creationDate;
|
||||
view.revisionDate = response.revisionDate;
|
||||
view.serviceAccountId = response.serviceAccountId;
|
||||
view.grantedProjectId = response.grantedProjectId;
|
||||
view.serviceAccountName = await this.encryptService.decryptToUtf8(
|
||||
new EncString(response.serviceAccountName),
|
||||
orgKey
|
||||
);
|
||||
view.grantedProjectName = await this.encryptService.decryptToUtf8(
|
||||
new EncString(response.grantedProjectName),
|
||||
orgKey
|
||||
);
|
||||
return view;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -63,8 +63,8 @@
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
[attr.title]="'close' | i18n"
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
[attr.title]="'remove' | i18n"
|
||||
[attr.aria-label]="'remove' | i18n"
|
||||
[bitAction]="delete(row.accessPolicyId)"
|
||||
></button>
|
||||
</td>
|
||||
|
@ -12,9 +12,9 @@ import { BaseAccessPolicyView } from "../../models/view/access-policy.view";
|
||||
import { AccessPolicyService } from "./access-policy.service";
|
||||
|
||||
export type AccessSelectorRowView = {
|
||||
type: "user" | "group" | "serviceAccount";
|
||||
type: "user" | "group" | "serviceAccount" | "project";
|
||||
name: string;
|
||||
granteeId: string;
|
||||
id: string;
|
||||
accessPolicyId: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
@ -30,6 +30,7 @@ export class AccessSelectorComponent implements OnInit {
|
||||
static readonly userIcon = "bwi-user";
|
||||
static readonly groupIcon = "bwi-family";
|
||||
static readonly serviceAccountIcon = "bwi-wrench";
|
||||
static readonly projectIcon = "bwi-collection";
|
||||
|
||||
@Output() onCreateAccessPolicies = new EventEmitter<SelectItemView[]>();
|
||||
|
||||
@ -37,7 +38,7 @@ export class AccessSelectorComponent implements OnInit {
|
||||
@Input() hint: string;
|
||||
@Input() columnTitle: string;
|
||||
@Input() emptyMessage: string;
|
||||
@Input() granteeType: "people" | "serviceAccounts";
|
||||
@Input() granteeType: "people" | "serviceAccounts" | "projects";
|
||||
|
||||
protected rows$ = new Subject<AccessSelectorRowView[]>();
|
||||
@Input() private set rows(value: AccessSelectorRowView[]) {
|
||||
@ -57,7 +58,7 @@ export class AccessSelectorComponent implements OnInit {
|
||||
switchMap(([rows, params]) =>
|
||||
this.getPotentialGrantees(params.organizationId).then((grantees) =>
|
||||
grantees
|
||||
.filter((g) => !rows.some((row) => row.granteeId === g.id))
|
||||
.filter((g) => !rows.some((row) => row.id === g.id))
|
||||
.map((granteeView) => {
|
||||
let icon: string;
|
||||
let listName = granteeView.name;
|
||||
@ -74,6 +75,8 @@ export class AccessSelectorComponent implements OnInit {
|
||||
icon = AccessSelectorComponent.groupIcon;
|
||||
} else if (granteeView.type === "serviceAccount") {
|
||||
icon = AccessSelectorComponent.serviceAccountIcon;
|
||||
} else if (granteeView.type === "project") {
|
||||
icon = AccessSelectorComponent.projectIcon;
|
||||
}
|
||||
return {
|
||||
icon: icon,
|
||||
@ -144,9 +147,14 @@ export class AccessSelectorComponent implements OnInit {
|
||||
};
|
||||
|
||||
private getPotentialGrantees(organizationId: string) {
|
||||
return this.granteeType === "people"
|
||||
? this.accessPolicyService.getPeoplePotentialGrantees(organizationId)
|
||||
: this.accessPolicyService.getServiceAccountsPotentialGrantees(organizationId);
|
||||
switch (this.granteeType) {
|
||||
case "people":
|
||||
return this.accessPolicyService.getPeoplePotentialGrantees(organizationId);
|
||||
case "serviceAccounts":
|
||||
return this.accessPolicyService.getServiceAccountsPotentialGrantees(organizationId);
|
||||
case "projects":
|
||||
return this.accessPolicyService.getProjectsPotentialGrantees(organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
static getAccessItemType(item: SelectItemView) {
|
||||
@ -157,6 +165,8 @@ export class AccessSelectorComponent implements OnInit {
|
||||
return "group";
|
||||
case AccessSelectorComponent.serviceAccountIcon:
|
||||
return "serviceAccount";
|
||||
case AccessSelectorComponent.projectIcon:
|
||||
return "project";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,5 @@
|
||||
export class GrantedPolicyRequest {
|
||||
grantedId: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
}
|
@ -73,11 +73,13 @@ export class ServiceAccountProjectAccessPolicyResponse extends BaseAccessPolicyR
|
||||
serviceAccountId: string;
|
||||
serviceAccountName: string;
|
||||
grantedProjectId: string;
|
||||
grantedProjectName: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.serviceAccountId = this.getResponseProperty("ServiceAccountId");
|
||||
this.serviceAccountName = this.getResponseProperty("ServiceAccountName");
|
||||
this.grantedProjectId = this.getResponseProperty("GrantedProjectId");
|
||||
this.grantedProjectName = this.getResponseProperty("GrantedProjectName");
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user