mirror of
https://github.com/goharbor/harbor.git
synced 2025-01-22 23:51:27 +01:00
fix(permissions): permissions checking for member and quota info (#9490)
1. Only show project member info when has member list permission. 2. Only show quota info when has quota read permission. 3. Add quota read permission for all roles of project. 4. Refactor permission service in portoal. 5. Clear cache when clear session. Closes #8697 Signed-off-by: He Weiwei <hweiwei@vmware.com>
This commit is contained in:
parent
91b7594442
commit
e254fe3095
@ -43,6 +43,7 @@ const (
|
||||
ResourceLog = Resource("log")
|
||||
ResourceMember = Resource("member")
|
||||
ResourceMetadata = Resource("metadata")
|
||||
ResourceQuota = Resource("quota")
|
||||
ResourceReplication = Resource("replication") // TODO remove
|
||||
ResourceReplicationJob = Resource("replication-job") // TODO remove
|
||||
ResourceReplicationExecution = Resource("replication-execution")
|
||||
|
@ -53,6 +53,8 @@ var (
|
||||
|
||||
{Resource: rbac.ResourceLabelResource, Action: rbac.ActionList},
|
||||
|
||||
{Resource: rbac.ResourceQuota, Action: rbac.ActionRead},
|
||||
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionCreate},
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionRead},
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate},
|
||||
@ -137,6 +139,8 @@ var (
|
||||
|
||||
{Resource: rbac.ResourceLog, Action: rbac.ActionList},
|
||||
|
||||
{Resource: rbac.ResourceQuota, Action: rbac.ActionRead},
|
||||
|
||||
{Resource: rbac.ResourceReplication, Action: rbac.ActionRead},
|
||||
{Resource: rbac.ResourceReplication, Action: rbac.ActionList},
|
||||
|
||||
@ -220,6 +224,8 @@ var (
|
||||
{Resource: rbac.ResourceLabel, Action: rbac.ActionRead},
|
||||
{Resource: rbac.ResourceLabel, Action: rbac.ActionList},
|
||||
|
||||
{Resource: rbac.ResourceQuota, Action: rbac.ActionRead},
|
||||
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionCreate},
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionRead},
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate},
|
||||
@ -273,6 +279,8 @@ var (
|
||||
{Resource: rbac.ResourceLabel, Action: rbac.ActionRead},
|
||||
{Resource: rbac.ResourceLabel, Action: rbac.ActionList},
|
||||
|
||||
{Resource: rbac.ResourceQuota, Action: rbac.ActionRead},
|
||||
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionRead},
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionList},
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionPull},
|
||||
@ -303,6 +311,8 @@ var (
|
||||
"limitedGuest": {
|
||||
{Resource: rbac.ResourceSelf, Action: rbac.ActionRead},
|
||||
|
||||
{Resource: rbac.ResourceQuota, Action: rbac.ActionRead},
|
||||
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionList},
|
||||
{Resource: rbac.ResourceRepository, Action: rbac.ActionPull},
|
||||
|
||||
|
@ -593,8 +593,18 @@ func (p *ProjectAPI) Summary() {
|
||||
ChartCount: p.project.ChartCount,
|
||||
}
|
||||
|
||||
var fetchSummaries []func(int64, *models.ProjectSummary)
|
||||
|
||||
if hasPerm, _ := p.HasProjectPermission(p.project.ProjectID, rbac.ActionRead, rbac.ResourceQuota); hasPerm {
|
||||
fetchSummaries = append(fetchSummaries, getProjectQuotaSummary)
|
||||
}
|
||||
|
||||
if hasPerm, _ := p.HasProjectPermission(p.project.ProjectID, rbac.ActionList, rbac.ResourceMember); hasPerm {
|
||||
fetchSummaries = append(fetchSummaries, getProjectMemberSummary)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, fn := range []func(int64, *models.ProjectSummary){getProjectQuotaSummary, getProjectMemberSummary} {
|
||||
for _, fn := range fetchSummaries {
|
||||
fn := fn
|
||||
|
||||
wg.Add(1)
|
||||
@ -685,7 +695,6 @@ func getProjectMemberSummary(projectID int64, summary *models.ProjectSummary) {
|
||||
{common.RoleMaster, &summary.MasterCount},
|
||||
{common.RoleDeveloper, &summary.DeveloperCount},
|
||||
{common.RoleGuest, &summary.GuestCount},
|
||||
{common.RoleLimitedGuest, &summary.LimitedGuestCount},
|
||||
} {
|
||||
wg.Add(1)
|
||||
go func(role int, count *int64) {
|
||||
|
103
src/portal/lib/src/cache/index.ts
vendored
Normal file
103
src/portal/lib/src/cache/index.ts
vendored
Normal file
@ -0,0 +1,103 @@
|
||||
// Copyright Project Harbor 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 { Observable, of } from "rxjs";
|
||||
import { tap, publishReplay, refCount } from "rxjs/operators";
|
||||
|
||||
function hashCode(str: string): string {
|
||||
let hash: number = 0;
|
||||
let chr: number;
|
||||
if (str.length === 0) {
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
chr = str.charCodeAt(i);
|
||||
|
||||
/* tslint:disable:no-bitwise */
|
||||
hash = ((hash << 5) - hash) + chr;
|
||||
hash |= 0;
|
||||
/* tslint:enable:no-bitwise */
|
||||
}
|
||||
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
interface IObservableCacheValue {
|
||||
response: Observable<any>;
|
||||
|
||||
/**
|
||||
* created time of the cache value
|
||||
*/
|
||||
created?: Date;
|
||||
}
|
||||
|
||||
interface IObservableCacheConfig {
|
||||
/**
|
||||
* maxAge of cache in milliseconds
|
||||
*/
|
||||
maxAge?: number;
|
||||
|
||||
/**
|
||||
* whether should use sliding expiration on caches
|
||||
*/
|
||||
slidingExpiration?: boolean;
|
||||
}
|
||||
|
||||
const cache: Map<string, IObservableCacheValue> = new Map<string, IObservableCacheValue>();
|
||||
|
||||
export function CacheObservable(config: IObservableCacheConfig = {}) {
|
||||
return function (target: any, methodName: string, descriptor: PropertyDescriptor) {
|
||||
const original = descriptor.value;
|
||||
const targetName = target.constructor.name;
|
||||
|
||||
(descriptor.value as any) = function (...args: Array<any>) {
|
||||
const key = hashCode(`${targetName}:${methodName}:${JSON.stringify(args)}`);
|
||||
|
||||
let value = cache.get(key);
|
||||
if (value && value.created) {
|
||||
if (new Date().getTime() - new Date(value.created).getTime() > config.maxAge) {
|
||||
cache[key] = null;
|
||||
value = null;
|
||||
} else if (config.slidingExpiration) {
|
||||
value.created = new Date();
|
||||
cache.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (value) {
|
||||
return of(value.response);
|
||||
}
|
||||
|
||||
const response$ = (original.apply(this, args) as Observable<any>).pipe(
|
||||
tap((response: Observable<any>) => {
|
||||
cache.set(key, {
|
||||
response: response,
|
||||
created: config.maxAge ? new Date() : null
|
||||
});
|
||||
}),
|
||||
publishReplay(1),
|
||||
refCount()
|
||||
);
|
||||
|
||||
return response$;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
export function FlushAll() {
|
||||
cache.clear();
|
||||
}
|
@ -29,4 +29,5 @@ export * from "./repository-gridview/index";
|
||||
export * from "./operation/index";
|
||||
export * from "./_animations/index";
|
||||
export * from "./cron-schedule/index";
|
||||
export * from "./cache/index";
|
||||
|
||||
|
@ -53,6 +53,12 @@ export const USERSTATICPERMISSION = {
|
||||
"READ": "read",
|
||||
}
|
||||
},
|
||||
"QUOTA": {
|
||||
"KEY": "quota",
|
||||
"VALUE": {
|
||||
"READ": "read"
|
||||
}
|
||||
},
|
||||
"REPOSITORY": {
|
||||
'KEY': 'repository',
|
||||
'VALUE': {
|
||||
|
@ -13,14 +13,16 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, throwError as observableThrowError } from "rxjs";
|
||||
import { map, catchError, shareReplay } from "rxjs/operators";
|
||||
import { UserPrivilegeServeItem } from './interface';
|
||||
import { Observable, forkJoin, of, throwError as observableThrowError } from "rxjs";
|
||||
import { map, tap, publishReplay, refCount } from "rxjs/operators";
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { CacheObservable } from '../cache';
|
||||
|
||||
interface Permission {
|
||||
resource: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
|
||||
const CACHE_SIZE = 1;
|
||||
/**
|
||||
* Get System privilege about current backend server.
|
||||
* @abstract
|
||||
@ -35,8 +37,11 @@ export abstract class UserPermissionService {
|
||||
*/
|
||||
abstract getPermission(projectId, resource, action);
|
||||
abstract clearPermissionCache();
|
||||
abstract hasProjectPermission(projectId: any, permission: Permission): Observable<boolean>;
|
||||
abstract hasProjectPermissions(projectId: any, permissions: Array<Permission>): Observable<Array<boolean>>;
|
||||
}
|
||||
|
||||
// @dynamic
|
||||
@Injectable()
|
||||
export class UserPermissionDefaultService extends UserPermissionService {
|
||||
constructor(
|
||||
@ -44,36 +49,37 @@ export class UserPermissionDefaultService extends UserPermissionService {
|
||||
) {
|
||||
super();
|
||||
}
|
||||
private permissionCache: Observable<object>;
|
||||
private projectId: number;
|
||||
private getPermissionFromBackend(projectId): Observable<object> {
|
||||
const userPermissionUrl = `/api/users/current/permissions?scope=/project/${projectId}&relative=true`;
|
||||
return this.http.get(userPermissionUrl);
|
||||
}
|
||||
private processingPermissionResult(responsePermission, resource, action): boolean {
|
||||
const permissionList = responsePermission as UserPrivilegeServeItem[];
|
||||
for (const privilegeItem of permissionList) {
|
||||
if (privilegeItem.resource === resource && privilegeItem.action === action) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
public getPermission(projectId, resource, action): Observable<boolean> {
|
||||
|
||||
if (!this.permissionCache || this.projectId !== +projectId) {
|
||||
this.projectId = +projectId;
|
||||
this.permissionCache = this.getPermissionFromBackend(projectId).pipe(
|
||||
shareReplay(CACHE_SIZE));
|
||||
}
|
||||
return this.permissionCache.pipe(map(response => {
|
||||
return this.processingPermissionResult(response, resource, action);
|
||||
}))
|
||||
.pipe(catchError(error => observableThrowError(error)
|
||||
@CacheObservable({ maxAge: 1000 * 60 })
|
||||
private getPermissions(scope: string, relative?: boolean): Observable<Array<Permission>> {
|
||||
const url = `/api/users/current/permissions?scope=${scope}&relative=${relative ? 'true' : 'false'}`;
|
||||
return this.http.get<Array<Permission>>(url);
|
||||
}
|
||||
|
||||
private hasPermission(permission: Permission, scope: string, relative?: boolean): Observable<boolean> {
|
||||
return this.getPermissions(scope, relative).pipe(map(
|
||||
(permissions: Array<Permission>) => {
|
||||
return permissions.some((p: Permission) => p.resource === permission.resource && p.action === permission.action);
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
private hasPermissions(permissions: Array<Permission>, scope: string, relative?: boolean): Observable<Array<boolean>> {
|
||||
return forkJoin(permissions.map((permission) => this.hasPermission(permission, scope, relative)));
|
||||
}
|
||||
|
||||
public hasProjectPermission(projectId: any, permission: Permission): Observable<boolean> {
|
||||
return this.hasPermission(permission, `/project/${projectId}`, true);
|
||||
}
|
||||
|
||||
public hasProjectPermissions(projectId: any, permissions: Array<Permission>): Observable<Array<boolean>> {
|
||||
return this.hasPermissions(permissions, `/project/${projectId}`, true);
|
||||
}
|
||||
|
||||
public getPermission(projectId: any, resource: string, action: string): Observable<boolean> {
|
||||
return this.hasProjectPermission(projectId, { resource, action });
|
||||
}
|
||||
|
||||
public clearPermissionCache() {
|
||||
this.permissionCache = null;
|
||||
this.projectId = null;
|
||||
}
|
||||
}
|
||||
|
@ -12,18 +12,17 @@
|
||||
<li>{{summaryInformation?.chart_count}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="display-flex project-detail pt-1">
|
||||
<div *ngIf="showProjectMemberInfo" class="display-flex project-detail pt-1">
|
||||
<h5 class="mt-0">{{'SUMMARY.PROJECT_MEMBER' | translate}}</h5>
|
||||
<ul class="list-unstyled">
|
||||
<li>{{ summaryInformation?.project_admin_count }} {{'SUMMARY.ADMIN' | translate}}</li>
|
||||
<li>{{ summaryInformation?.master_count }} {{'SUMMARY.MASTER' | translate}}</li>
|
||||
<li>{{ summaryInformation?.developer_count }} {{'SUMMARY.DEVELOPER' | translate}}</li>
|
||||
<li>{{ summaryInformation?.guest_count }} {{'SUMMARY.GUEST' | translate}}</li>
|
||||
<li>{{ summaryInformation?.limited_guest_count }} {{'SUMMARY.LIMITED_GUEST' | translate}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-right pt-1">
|
||||
<div *ngIf="showQuotaInfo" class="summary-right pt-1">
|
||||
<div class="display-flex project-detail">
|
||||
<h5 class="mt-0">{{'SUMMARY.PROJECT_QUOTAS' | translate}}</h5>
|
||||
<div class="ml-1">
|
||||
|
@ -2,12 +2,13 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ClarityModule } from '@clr/angular';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { ProjectService, ErrorHandler} from '@harbor/ui';
|
||||
import { ProjectService, ErrorHandler, UserPermissionService } from '@harbor/ui';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { AppConfigService } from "../../app-config.service";
|
||||
import { SummaryComponent } from './summary.component';
|
||||
|
||||
|
||||
describe('SummaryComponent', () => {
|
||||
let component: SummaryComponent;
|
||||
let fixture: ComponentFixture<SummaryComponent>;
|
||||
@ -18,6 +19,11 @@ describe('SummaryComponent', () => {
|
||||
}
|
||||
};
|
||||
let fakeErrorHandler = null;
|
||||
let fakeUserPermissionService = {
|
||||
hasProjectPermissions: function() {
|
||||
return of([true, true]);
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@ -34,6 +40,7 @@ describe('SummaryComponent', () => {
|
||||
{ provide: AppConfigService, useValue: fakeAppConfigService },
|
||||
{ provide: ProjectService, useValue: fakeProjectService },
|
||||
{ provide: ErrorHandler, useValue: fakeErrorHandler },
|
||||
{ provide: UserPermissionService, useValue: fakeUserPermissionService },
|
||||
{
|
||||
provide: ActivatedRoute, useValue: {
|
||||
paramMap: of({ get: (key) => 'value' }),
|
||||
|
@ -1,41 +1,70 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ProjectService, clone, QuotaUnits, getSuitableUnit, ErrorHandler, GetIntegerAndUnit
|
||||
, QUOTA_DANGER_COEFFICIENT, QUOTA_WARNING_COEFFICIENT } from '@harbor/ui';
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import {
|
||||
ProjectService,
|
||||
clone,
|
||||
QuotaUnits,
|
||||
getSuitableUnit,
|
||||
ErrorHandler,
|
||||
GetIntegerAndUnit,
|
||||
UserPermissionService,
|
||||
USERSTATICPERMISSION,
|
||||
QUOTA_DANGER_COEFFICIENT,
|
||||
QUOTA_WARNING_COEFFICIENT
|
||||
} from '@harbor/ui';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { AppConfigService } from "../../app-config.service";
|
||||
|
||||
@Component({
|
||||
selector: 'summary',
|
||||
templateUrl: './summary.component.html',
|
||||
styleUrls: ['./summary.component.scss']
|
||||
})
|
||||
export class SummaryComponent implements OnInit {
|
||||
showProjectMemberInfo: boolean;
|
||||
showQuotaInfo: boolean;
|
||||
|
||||
projectId: number;
|
||||
summaryInformation: any;
|
||||
quotaDangerCoefficient: number = QUOTA_DANGER_COEFFICIENT;
|
||||
quotaWarningCoefficient: number = QUOTA_WARNING_COEFFICIENT;
|
||||
constructor(
|
||||
private projectService: ProjectService,
|
||||
private userPermissionService: UserPermissionService,
|
||||
private errorHandler: ErrorHandler,
|
||||
private appConfigService: AppConfigService,
|
||||
private route: ActivatedRoute
|
||||
) { }
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.projectId = this.route.snapshot.parent.params['id'];
|
||||
|
||||
const permissions = [
|
||||
{ resource: USERSTATICPERMISSION.MEMBER.KEY, action: USERSTATICPERMISSION.MEMBER.VALUE.LIST },
|
||||
{ resource: USERSTATICPERMISSION.QUOTA.KEY, action: USERSTATICPERMISSION.QUOTA.VALUE.READ },
|
||||
];
|
||||
|
||||
this.userPermissionService.hasProjectPermissions(this.projectId, permissions).subscribe((results: Array<boolean>) => {
|
||||
this.showProjectMemberInfo = results[0];
|
||||
this.showQuotaInfo = results[1];
|
||||
});
|
||||
|
||||
this.projectService.getProjectSummary(this.projectId).subscribe(res => {
|
||||
this.summaryInformation = res;
|
||||
}, error => {
|
||||
this.errorHandler.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
getSuitableUnit(value) {
|
||||
const QuotaUnitsCopy = clone(QuotaUnits);
|
||||
return getSuitableUnit(value, QuotaUnitsCopy);
|
||||
}
|
||||
|
||||
getIntegerAndUnit(hardValue, usedValue) {
|
||||
return GetIntegerAndUnit(hardValue, clone(QuotaUnits), usedValue, clone(QuotaUnits));
|
||||
}
|
||||
|
||||
public get withHelmChart(): boolean {
|
||||
return this.appConfigService.getConfig().with_chartmuseum;
|
||||
}
|
||||
|
@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
import { Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { ErrorHandler, UserPermissionService, httpStatusCode, errorHandler } from '@harbor/ui';
|
||||
import { ErrorHandler, httpStatusCode, errorHandler } from '@harbor/ui';
|
||||
|
||||
import { AlertType } from '../../shared/shared.const';
|
||||
import { MessageService } from '../../global-message/message.service';
|
||||
@ -26,7 +26,6 @@ export class MessageHandlerService implements ErrorHandler {
|
||||
constructor(
|
||||
private msgService: MessageService,
|
||||
private translate: TranslateService,
|
||||
private userPermissionService: UserPermissionService,
|
||||
private session: SessionService) { }
|
||||
|
||||
// Handle the error and map it to the suitable message
|
||||
@ -46,7 +45,6 @@ export class MessageHandlerService implements ErrorHandler {
|
||||
this.msgService.announceAppLevelMessage(code, msg, AlertType.DANGER);
|
||||
// Session is invalid now, clare session cache
|
||||
this.session.clear();
|
||||
this.userPermissionService.clearPermissionCache();
|
||||
} else {
|
||||
this.msgService.announceMessage(code, msg, AlertType.DANGER);
|
||||
}
|
||||
|
@ -19,12 +19,12 @@ import {
|
||||
CanActivateChild
|
||||
} from '@angular/router';
|
||||
import { SessionService } from '../../shared/session.service';
|
||||
import { CommonRoutes, UserPermissionService } from '@harbor/ui';
|
||||
import { CommonRoutes } from '@harbor/ui';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class SignInGuard implements CanActivate, CanActivateChild {
|
||||
constructor(private authService: SessionService, private router: Router, private userPermission: UserPermissionService) { }
|
||||
constructor(private authService: SessionService, private router: Router) { }
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean {
|
||||
// If user has logged in, should not login again
|
||||
@ -35,7 +35,6 @@ export class SignInGuard implements CanActivate, CanActivateChild {
|
||||
this.authService.signOff()
|
||||
.subscribe(() => {
|
||||
this.authService.clear(); // Destroy session cache
|
||||
this.userPermission.clearPermissionCache();
|
||||
|
||||
return observer.next(true);
|
||||
}, error => {
|
||||
|
@ -21,7 +21,7 @@ import { Member } from '../project/member/member';
|
||||
|
||||
import { SignInCredential } from './sign-in-credential';
|
||||
import { enLang } from '../shared/shared.const';
|
||||
import { HTTP_FORM_OPTIONS, HTTP_JSON_OPTIONS, HTTP_GET_OPTIONS } from "@harbor/ui";
|
||||
import { HTTP_FORM_OPTIONS, HTTP_JSON_OPTIONS, HTTP_GET_OPTIONS, FlushAll } from "@harbor/ui";
|
||||
|
||||
const signInUrl = '/c/login';
|
||||
const currentUserEndpoint = "/api/users/current";
|
||||
@ -62,6 +62,7 @@ export class SessionService {
|
||||
clear(): void {
|
||||
this.currentUser = null;
|
||||
this.projectMembers = [];
|
||||
FlushAll();
|
||||
}
|
||||
|
||||
// Submit signin form to backend (NOT restful service)
|
||||
|
Loading…
Reference in New Issue
Block a user