mirror of
https://github.com/goharbor/harbor.git
synced 2024-11-28 05:05:19 +01:00
Merge pull request #11073 from AllForNothing/permission
Swith to new API for recent log page
This commit is contained in:
commit
2b6fb4abcf
@ -456,7 +456,9 @@
|
||||
"FILTER_PLACEHOLDER": "Filter Logs",
|
||||
"INVALID_DATE": "Invalid date.",
|
||||
"OF": "of",
|
||||
"NOT_FOUND": "We couldn't find any logs!"
|
||||
"NOT_FOUND": "We couldn't find any logs!",
|
||||
"RESOURCE": "Resource",
|
||||
"RESOURCE_TYPE": "Resource Type"
|
||||
},
|
||||
"REPLICATION": {
|
||||
"YES": "Yes",
|
||||
|
@ -456,7 +456,9 @@
|
||||
"FILTER_PLACEHOLDER": "Filtrar logs",
|
||||
"INVALID_DATE": "Fecha invalida.",
|
||||
"OF": "of",
|
||||
"NOT_FOUND": "No pudimos encontrar ningún registro!"
|
||||
"NOT_FOUND": "No pudimos encontrar ningún registro!",
|
||||
"RESOURCE": "Resource",
|
||||
"RESOURCE_TYPE": "Resource Type"
|
||||
},
|
||||
"REPLICATION": {
|
||||
"YES": "Yes",
|
||||
|
@ -448,7 +448,9 @@
|
||||
"FILTER_PLACEHOLDER": "Filtrer les Logs",
|
||||
"INVALID_DATE": "Date invalide.",
|
||||
"OF": "de",
|
||||
"NOT_FOUND": "Nous n'avons trouvé aucun journal!"
|
||||
"NOT_FOUND": "Nous n'avons trouvé aucun journal!",
|
||||
"RESOURCE": "Resource",
|
||||
"RESOURCE_TYPE": "Resource Type"
|
||||
},
|
||||
"REPLICATION": {
|
||||
"YES": "Yes",
|
||||
|
@ -454,7 +454,9 @@
|
||||
"FILTER_PLACEHOLDER": "Filtrar Logs",
|
||||
"INVALID_DATE": "Data inválida.",
|
||||
"OF": "de",
|
||||
"NOT_FOUND": "Nós não encontramos nenhum registro!"
|
||||
"NOT_FOUND": "Nós não encontramos nenhum registro!",
|
||||
"RESOURCE": "Resource",
|
||||
"RESOURCE_TYPE": "Resource Type"
|
||||
},
|
||||
"REPLICATION": {
|
||||
"YES": "Yes",
|
||||
|
@ -455,7 +455,10 @@
|
||||
"ITEMS": "adet",
|
||||
"FILTER_PLACEHOLDER": "Günlükleri Filtrele",
|
||||
"INVALID_DATE": "Geçersiz tarih.",
|
||||
"OF": "of"
|
||||
"OF": "of",
|
||||
"NOT_FOUND": "We couldn't find any logs!",
|
||||
"RESOURCE": "Resource",
|
||||
"RESOURCE_TYPE": "Resource Type"
|
||||
},
|
||||
"REPLICATION": {
|
||||
"YES": "Evet",
|
||||
|
@ -455,7 +455,9 @@
|
||||
"FILTER_PLACEHOLDER": "过滤日志",
|
||||
"INVALID_DATE": "无效日期。",
|
||||
"OF": "共计",
|
||||
"NOT_FOUND": "未发现任何日志!"
|
||||
"NOT_FOUND": "未发现任何日志!",
|
||||
"RESOURCE": "资源",
|
||||
"RESOURCE_TYPE": "资源类型"
|
||||
},
|
||||
"REPLICATION": {
|
||||
"YES": "是",
|
||||
|
@ -6,8 +6,8 @@
|
||||
<div class="select filter-tag clr-select-wrapper" [hidden]="!isOpenFilterTag">
|
||||
<select id="selectKey" (change)="selectFilterKey($event)">
|
||||
<option value="username">{{"AUDIT_LOG.USERNAME" | translate | lowercase}}</option>
|
||||
<option value="repository">{{"CONFIG.REPOSITORY" | translate | lowercase}}</option>
|
||||
<option value="tag">{{"REPOSITORY.TAG" | translate | lowercase}}</option>
|
||||
<option value="resource">{{"AUDIT_LOG.RESOURCE" | translate | lowercase}}</option>
|
||||
<option value="resourceType">{{"AUDIT_LOG.RESOURCE_TYPE" | translate | lowercase}}</option>
|
||||
<option value="operation">{{"AUDIT_LOG.OPERATION" | translate | lowercase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -20,17 +20,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<clr-datagrid (clrDgRefresh)="load($event)" [clrDgLoading]="loading">
|
||||
<clr-datagrid (clrDgRefresh)="load()" [clrDgLoading]="loading">
|
||||
<clr-dg-column>{{'AUDIT_LOG.USERNAME' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'AUDIT_LOG.REPOSITORY_NAME' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'AUDIT_LOG.TAGS' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'AUDIT_LOG.RESOURCE' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'AUDIT_LOG.RESOURCE_TYPE' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'AUDIT_LOG.OPERATION' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'AUDIT_LOG.TIMESTAMP' | translate}}</clr-dg-column>
|
||||
<clr-dg-placeholder>{{ 'AUDIT_LOG.NOT_FOUND' | translate }}</clr-dg-placeholder>
|
||||
<clr-dg-row *ngFor="let l of recentLogs">
|
||||
<clr-dg-cell>{{l.username}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{l.repo_name}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{l.repo_tag}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{l.resource}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{l.resource_type}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{l.operation}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{l.op_time | date: 'short'}}</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
|
@ -1,41 +1,79 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { AccessLog, AccessLogItem, RequestQueryParams } from '../../services';
|
||||
|
||||
import { RecentLogComponent } from './recent-log.component';
|
||||
import { AccessLogService, AccessLogDefaultService } from '../../services/access-log.service';
|
||||
import { SERVICE_CONFIG, IServiceConfig } from '../../entities/service.config';
|
||||
import { ErrorHandler } from '../../utils/error-handler';
|
||||
import { SharedModule } from '../../utils/shared/shared.module';
|
||||
import { FilterComponent } from '../filter/filter.component';
|
||||
|
||||
import { click, CURRENT_BASE_HREF } from '../../utils/utils';
|
||||
import { of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { AuditLog } from "../../../../ng-swagger-gen/models/audit-log";
|
||||
import { AuditlogService } from "../../../../ng-swagger-gen/services/auditlog.service";
|
||||
import { HttpHeaders, HttpResponse } from "@angular/common/http";
|
||||
import ListAuditLogsParams = AuditlogService.ListAuditLogsParams;
|
||||
import { delay } from "rxjs/operators";
|
||||
|
||||
describe('RecentLogComponent (inline template)', () => {
|
||||
let component: RecentLogComponent;
|
||||
let fixture: ComponentFixture<RecentLogComponent>;
|
||||
let serviceConfig: IServiceConfig;
|
||||
let logService: AccessLogService;
|
||||
let spy: jasmine.Spy;
|
||||
let mockItems: AccessLogItem[] = [];
|
||||
let mockData: AccessLog = {
|
||||
metadata: {
|
||||
xTotalCount: 18
|
||||
},
|
||||
data: []
|
||||
};
|
||||
let mockData2: AccessLog = {
|
||||
metadata: {
|
||||
xTotalCount: 1
|
||||
},
|
||||
data: []
|
||||
};
|
||||
let auditlogService: AuditlogService;
|
||||
let testConfig: IServiceConfig = {
|
||||
logBaseEndpoint: CURRENT_BASE_HREF + "/logs/testing"
|
||||
};
|
||||
|
||||
const fakedErrorHandler = {
|
||||
error() {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const mockedAuditLogs: AuditLog [] = [];
|
||||
for (let i = 0; i < 18; i++) {
|
||||
let item: AuditLog = {
|
||||
id: 23 + i,
|
||||
resource: "myproject/demo" + i,
|
||||
resource_type: "N/A",
|
||||
operation: "create",
|
||||
op_time: "2017-04-11T10:26:22Z",
|
||||
username: "user91" + i
|
||||
};
|
||||
mockedAuditLogs.push(item);
|
||||
}
|
||||
const fakedAuditlogService = {
|
||||
listAuditLogsResponse(params: ListAuditLogsParams) {
|
||||
if (params && params.username) {
|
||||
if (params.username === 'demo0') {
|
||||
return of(new HttpResponse({
|
||||
body: mockedAuditLogs.slice(0, 1),
|
||||
headers: new HttpHeaders({
|
||||
"x-total-count": "18"
|
||||
})
|
||||
})).pipe(delay(0));
|
||||
}
|
||||
return of(new HttpResponse({
|
||||
body: mockedAuditLogs,
|
||||
headers: new HttpHeaders({
|
||||
"x-total-count": "18"
|
||||
})
|
||||
})).pipe(delay(0));
|
||||
} else {
|
||||
if (params.page === 1) {
|
||||
return of(new HttpResponse({
|
||||
body: mockedAuditLogs.slice(0, 15),
|
||||
headers: new HttpHeaders({
|
||||
"x-total-count": "18"
|
||||
})
|
||||
})).pipe(delay(0));
|
||||
} else {
|
||||
return of(new HttpResponse({
|
||||
body: mockedAuditLogs.slice(15),
|
||||
headers: new HttpHeaders({
|
||||
"x-total-count": "18"
|
||||
})
|
||||
})).pipe(delay(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
@ -43,9 +81,9 @@ describe('RecentLogComponent (inline template)', () => {
|
||||
],
|
||||
declarations: [FilterComponent, RecentLogComponent],
|
||||
providers: [
|
||||
ErrorHandler,
|
||||
{ provide: ErrorHandler, useValue: fakedErrorHandler },
|
||||
{ provide: AuditlogService, useValue: fakedAuditlogService },
|
||||
{ provide: SERVICE_CONFIG, useValue: testConfig },
|
||||
{ provide: AccessLogService, useClass: AccessLogDefaultService }
|
||||
]
|
||||
});
|
||||
|
||||
@ -55,38 +93,7 @@ describe('RecentLogComponent (inline template)', () => {
|
||||
fixture = TestBed.createComponent(RecentLogComponent);
|
||||
component = fixture.componentInstance;
|
||||
serviceConfig = TestBed.get(SERVICE_CONFIG);
|
||||
logService = fixture.debugElement.injector.get(AccessLogService);
|
||||
// Mock data
|
||||
for (let i = 0; i < 18; i++) {
|
||||
let item: AccessLogItem = {
|
||||
log_id: 23 + i,
|
||||
user_id: 45 + i,
|
||||
project_id: 11 + i,
|
||||
repo_name: "myproject/demo" + i,
|
||||
repo_tag: "N/A",
|
||||
operation: "create",
|
||||
op_time: "2017-04-11T10:26:22Z",
|
||||
username: "user91" + i
|
||||
};
|
||||
mockItems.push(item);
|
||||
}
|
||||
mockData2.data = mockItems.slice(0, 1);
|
||||
mockData.data = mockItems;
|
||||
|
||||
spy = spyOn(logService, 'getRecentLogs')
|
||||
.and.callFake(function (params: RequestQueryParams) {
|
||||
if (params && params.get('username')) {
|
||||
return of(mockData2);
|
||||
} else {
|
||||
if (params.get('page') === '1') {
|
||||
mockData.data = mockItems.slice(0, 15);
|
||||
} else {
|
||||
mockData.data = mockItems.slice(15, 18);
|
||||
}
|
||||
return of(mockData).pipe(delay(0));
|
||||
}
|
||||
});
|
||||
|
||||
auditlogService = fixture.debugElement.injector.get(AuditlogService);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@ -100,15 +107,11 @@ describe('RecentLogComponent (inline template)', () => {
|
||||
});
|
||||
|
||||
it('should get data from AccessLogService', async(() => {
|
||||
expect(logService).toBeTruthy();
|
||||
expect(spy.calls.any()).toBe(true, 'getRecentLogs called');
|
||||
|
||||
expect(auditlogService).toBeTruthy();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.whenStable().then(() => { // wait for async getRecentLogs
|
||||
fixture.detectChanges();
|
||||
expect(component.recentLogs).toBeTruthy();
|
||||
expect(component.logsCache).toBeTruthy();
|
||||
expect(component.recentLogs.length).toEqual(15);
|
||||
});
|
||||
}));
|
||||
@ -125,28 +128,16 @@ describe('RecentLogComponent (inline template)', () => {
|
||||
expect(el.textContent.trim()).toEqual('user910');
|
||||
});
|
||||
}));
|
||||
|
||||
// Will fail after upgrade to angular 6. todo: need to fix it.
|
||||
xit('should support pagination', () => {
|
||||
it('should support pagination', async () => {
|
||||
fixture.autoDetectChanges(true);
|
||||
await fixture.whenStable();
|
||||
let el: HTMLButtonElement = fixture.nativeElement.querySelector('.pagination-next');
|
||||
expect(el).toBeTruthy();
|
||||
el.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
let el: HTMLButtonElement = fixture.nativeElement.querySelector('.pagination-next');
|
||||
expect(el).toBeTruthy();
|
||||
el.click();
|
||||
jasmine.clock().tick(100);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
let els: HTMLElement[] = fixture.nativeElement.querySelectorAll('.datagrid-row');
|
||||
expect(els).toBeTruthy();
|
||||
expect(els.length).toEqual(4);
|
||||
});
|
||||
});
|
||||
await fixture.whenStable();
|
||||
expect(component.currentPage).toEqual(2);
|
||||
expect(component.recentLogs.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should support filtering list by keywords', async(() => {
|
||||
@ -154,22 +145,17 @@ describe('RecentLogComponent (inline template)', () => {
|
||||
let el: HTMLElement = fixture.nativeElement.querySelector('.search-btn');
|
||||
expect(el).toBeTruthy("Not found search icon");
|
||||
click(el);
|
||||
|
||||
fixture.detectChanges();
|
||||
let el2: HTMLInputElement = fixture.nativeElement.querySelector('input');
|
||||
expect(el2).toBeTruthy("Not found input");
|
||||
|
||||
fixture.detectChanges();
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.doFilter("demo0");
|
||||
|
||||
fixture.detectChanges();
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
expect(component.recentLogs).toBeTruthy();
|
||||
expect(component.logsCache).toBeTruthy();
|
||||
expect(component.recentLogs.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
@ -177,32 +163,23 @@ describe('RecentLogComponent (inline template)', () => {
|
||||
|
||||
it('should support refreshing', async(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
let el: HTMLButtonElement = fixture.nativeElement.querySelector('.pagination-next');
|
||||
expect(el).toBeTruthy();
|
||||
el.click();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
expect(component.recentLogs).toBeTruthy();
|
||||
expect(component.logsCache).toBeTruthy();
|
||||
expect(component.recentLogs.length).toEqual(3);
|
||||
|
||||
let refreshEl: HTMLElement = fixture.nativeElement.querySelector(".refresh-btn");
|
||||
expect(refreshEl).toBeTruthy("Not found refresh button");
|
||||
refreshEl.click();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
expect(component.recentLogs).toBeTruthy();
|
||||
expect(component.logsCache).toBeTruthy();
|
||||
expect(component.recentLogs.length).toEqual(15);
|
||||
});
|
||||
|
||||
|
@ -12,20 +12,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
import { Component, OnInit, Input } from '@angular/core';
|
||||
import { Comparator, State } from '../../services/interface';
|
||||
|
||||
import {
|
||||
AccessLogService,
|
||||
AccessLog,
|
||||
AccessLogItem,
|
||||
RequestQueryParams
|
||||
} from '../../services';
|
||||
import { ErrorHandler } from '../../utils/error-handler';
|
||||
import { CustomComparator } from '../../utils/utils';
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
} from '../../utils/utils';
|
||||
import { finalize } from "rxjs/operators";
|
||||
import { AuditlogService } from "../../../../ng-swagger-gen/services/auditlog.service";
|
||||
import { AuditLog } from "../../../../ng-swagger-gen/models/audit-log";
|
||||
import ListAuditLogsParams = AuditlogService.ListAuditLogsParams;
|
||||
|
||||
@Component({
|
||||
selector: 'hbr-log',
|
||||
@ -34,8 +25,7 @@ import { finalize } from "rxjs/operators";
|
||||
})
|
||||
|
||||
export class RecentLogComponent implements OnInit {
|
||||
recentLogs: AccessLogItem[] = [];
|
||||
logsCache: AccessLog;
|
||||
recentLogs: AuditLog[] = [];
|
||||
loading: boolean = true;
|
||||
currentTerm: string;
|
||||
defaultFilter = "username";
|
||||
@ -43,17 +33,13 @@ export class RecentLogComponent implements OnInit {
|
||||
@Input() withTitle: boolean = false;
|
||||
pageSize: number = 15;
|
||||
currentPage: number = 1; // Double bound to pagination component
|
||||
totalCount: number = 0;
|
||||
constructor(
|
||||
private logService: AccessLogService,
|
||||
private logService: AuditlogService,
|
||||
private errorHandler: ErrorHandler) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
public get totalCount(): number {
|
||||
return this.logsCache && this.logsCache.metadata ? this.logsCache.metadata.xTotalCount : 0;
|
||||
}
|
||||
|
||||
public get inProgress(): boolean {
|
||||
return this.loading;
|
||||
}
|
||||
@ -66,7 +52,8 @@ export class RecentLogComponent implements OnInit {
|
||||
this.currentTerm = terms.trim();
|
||||
this.loading = true;
|
||||
this.currentPage = 1;
|
||||
this.load({page: {}});
|
||||
this.totalCount = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
public refresh(): void {
|
||||
@ -74,11 +61,7 @@ export class RecentLogComponent implements OnInit {
|
||||
}
|
||||
|
||||
openFilter(isOpen: boolean): void {
|
||||
if (isOpen) {
|
||||
this.isOpenFilterTag = true;
|
||||
} else {
|
||||
this.isOpenFilterTag = false;
|
||||
}
|
||||
this.isOpenFilterTag = isOpen;
|
||||
}
|
||||
|
||||
selectFilterKey($event: any): void {
|
||||
@ -86,21 +69,27 @@ export class RecentLogComponent implements OnInit {
|
||||
this.doFilter(this.currentTerm);
|
||||
}
|
||||
|
||||
load(state) {
|
||||
if (!state || !state.page) {
|
||||
return;
|
||||
}
|
||||
load() {
|
||||
// Keep it for future filter
|
||||
// this.currentState = state;
|
||||
let params: RequestQueryParams = new RequestQueryParams().set("page", '' + this.currentPage).set("page_size", '' + this.pageSize);
|
||||
const params: ListAuditLogsParams = {
|
||||
page: this.currentPage,
|
||||
pageSize: this.pageSize
|
||||
};
|
||||
if (this.currentTerm && this.currentTerm !== "") {
|
||||
params = params.set(this.defaultFilter, this.currentTerm);
|
||||
params[this.defaultFilter] = this.currentTerm;
|
||||
}
|
||||
this.loading = true;
|
||||
this.logService.getRecentLogs(params).pipe(finalize(() => (this.loading = false)))
|
||||
this.logService.listAuditLogsResponse(params).pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe(response => {
|
||||
this.logsCache = response; // Keep the data
|
||||
this.recentLogs = response.data;
|
||||
// Get total count
|
||||
if (response.headers) {
|
||||
let xHeader: string = response.headers.get("x-total-count");
|
||||
if (xHeader) {
|
||||
this.totalCount = parseInt(xHeader, 0);
|
||||
}
|
||||
}
|
||||
this.recentLogs = response.body as AuditLog[];
|
||||
}, error => {
|
||||
this.errorHandler.error(error);
|
||||
});
|
||||
|
@ -13,8 +13,8 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, forkJoin, of, throwError as observableThrowError } from "rxjs";
|
||||
import { map, tap, publishReplay, refCount } from "rxjs/operators";
|
||||
import { Observable, forkJoin} from "rxjs";
|
||||
import { map, share } from "rxjs/operators";
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { CacheObservable } from "../utils/cache-util";
|
||||
import { CURRENT_BASE_HREF } from "../utils/utils";
|
||||
@ -46,6 +46,8 @@ export abstract class UserPermissionService {
|
||||
// @dynamic
|
||||
@Injectable()
|
||||
export class UserPermissionDefaultService extends UserPermissionService {
|
||||
// to prevent duplicate permissions HTTP requests
|
||||
private _sharedPermissionObservableMap: {[key: string]: Observable<Array<Permission>>} = {};
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
) {
|
||||
@ -55,7 +57,12 @@ export class UserPermissionDefaultService extends UserPermissionService {
|
||||
@CacheObservable({ maxAge: 1000 * 60 })
|
||||
private getPermissions(scope: string, relative?: boolean): Observable<Array<Permission>> {
|
||||
const url = `${ CURRENT_BASE_HREF }/users/current/permissions?scope=${scope}&relative=${relative ? 'true' : 'false'}`;
|
||||
return this.http.get<Array<Permission>>(url);
|
||||
if (this._sharedPermissionObservableMap[url]) {
|
||||
return this._sharedPermissionObservableMap[url];
|
||||
} else {
|
||||
this._sharedPermissionObservableMap[url] = this.http.get<Array<Permission>>(url).pipe(share());
|
||||
return this._sharedPermissionObservableMap[url];
|
||||
}
|
||||
}
|
||||
|
||||
private hasPermission(permission: Permission, scope: string, relative?: boolean): Observable<boolean> {
|
||||
|
Loading…
Reference in New Issue
Block a user