Skip to content

Commit

Permalink
SF-2574 Create Serval Administration Panel
Browse files Browse the repository at this point in the history
  • Loading branch information
pmachapman committed Mar 17, 2024
1 parent 411d241 commit 4ce3934
Show file tree
Hide file tree
Showing 29 changed files with 940 additions and 18 deletions.
1 change: 1 addition & 0 deletions src/RealtimeServer/common/models/system-role.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export enum SystemRole {
ServalAdmin = 'serval_admin',
SystemAdmin = 'system_admin',
User = 'user',
None = 'none'
Expand Down
8 changes: 8 additions & 0 deletions src/RealtimeServer/common/services/project-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import { ProjectService } from './project-service';
const PROJECTS_COLLECTION = 'projects';

describe('ProjectService', () => {
it('allows serval admin to view any project', async () => {
const env = new TestEnvironment();
await env.createData();

const conn = clientConnect(env.server, 'servalAdmin', SystemRole.ServalAdmin);
await expect(fetchDoc(conn, PROJECTS_COLLECTION, 'project01')).resolves.not.toThrow();
});

it('allows system admin to view any project', async () => {
const env = new TestEnvironment();
await env.createData();
Expand Down
7 changes: 6 additions & 1 deletion src/RealtimeServer/common/services/project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ export abstract class ProjectService<T extends Project = Project> extends JsonDo
};

protected allowRead(_docId: string, doc: T, session: ConnectSession): boolean {
if (session.isServer || session.roles.includes(SystemRole.SystemAdmin) || Object.keys(doc).length === 0) {
if (
session.isServer ||
session.roles.includes(SystemRole.ServalAdmin) ||
session.roles.includes(SystemRole.SystemAdmin) ||
Object.keys(doc).length === 0
) {
return true;
}

Expand Down
2 changes: 2 additions & 0 deletions src/SIL.XForge.Scripture/ClientApp/ngsw-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"/login/",
"/connect-project",
"/connect-project/",
"/serval-administration",
"/serval-administration/",
"/system-administration",
"/system-administration/"
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { AuthGuard } from 'xforge-common/auth.guard';
import { SystemAdminAuthGuard } from 'xforge-common/system-admin-auth.guard';
import { SystemAdministrationComponent } from 'xforge-common/system-administration/system-administration.component';
import { ConnectProjectComponent } from './connect-project/connect-project.component';
import { JoinComponent } from './join/join.component';
import { ProjectComponent } from './project/project.component';
import { ServalAdminAuthGuard } from './serval-administration/serval-admin-auth.guard';
import { ServalAdministrationComponent } from './serval-administration/serval-administration.component';
import { ServalProjectComponent } from './serval-administration/serval-project.component';
import { SettingsComponent } from './settings/settings.component';
import { PageNotFoundComponent } from './shared/page-not-found/page-not-found.component';
import { SettingsAuthGuard, SyncAuthGuard } from './shared/project-router.guard';
import { StartComponent } from './start/start.component';
import { SyncComponent } from './sync/sync.component';
import { JoinComponent } from './join/join.component';

const routes: Routes = [
{ path: 'callback/auth0', component: StartComponent, canActivate: [AuthGuard] },
Expand All @@ -22,6 +25,8 @@ const routes: Routes = [
{ path: 'projects/:projectId/sync', component: SyncComponent, canActivate: [SyncAuthGuard] },
{ path: 'projects/:projectId', component: ProjectComponent, canActivate: [AuthGuard] },
{ path: 'projects', component: StartComponent, canActivate: [AuthGuard] },
{ path: 'serval-administration/:projectId', component: ServalProjectComponent, canActivate: [ServalAdminAuthGuard] },
{ path: 'serval-administration', component: ServalAdministrationComponent, canActivate: [ServalAdminAuthGuard] },
{ path: 'system-administration', component: SystemAdministrationComponent, canActivate: [SystemAdminAuthGuard] },
{ path: '**', component: PageNotFoundComponent }
];
Expand Down
17 changes: 16 additions & 1 deletion src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,24 @@
</div>
</div>
<mat-divider></mat-divider>
<button mat-menu-item *ngIf="isSystemAdmin" [disabled]="!isAppOnline" appRouterLink="/system-administration">
<button
mat-menu-item
*ngIf="isSystemAdmin"
id="system-admin-btn"
[disabled]="!isAppOnline"
appRouterLink="/system-administration"
>
{{ t("system_administration") }}
</button>
<button
mat-menu-item
*ngIf="isServalAdmin"
id="serval-admin-btn"
[disabled]="!isAppOnline"
appRouterLink="/serval-administration"
>
{{ t("serval_administration") }}
</button>
<button mat-menu-item appRouterLink="/projects" id="project-home-link">{{ t("project_home") }}</button>
<button mat-menu-item *ngIf="canChangePassword" (click)="changePassword()" [disabled]="!isAppOnline">
{{ t("change_password") }}
Expand Down
81 changes: 81 additions & 0 deletions src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Route, Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { CookieService } from 'ngx-cookie-service';
import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role';
import { User } from 'realtime-server/lib/esm/common/models/user';
import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data';
import { SFProject } from 'realtime-server/lib/esm/scriptureforge/models/sf-project';
Expand Down Expand Up @@ -305,6 +306,86 @@ describe('AppComponent', () => {
verify(mockedUserService.editDisplayName(anything())).never();
}));
});

describe('Serval Administrator', () => {
it('shows serval administration menu item', fakeAsync(() => {
const env = new TestEnvironment('online');
when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]);
env.init();

// Show the user menu
env.avatarIcon.nativeElement.click();
env.wait();

// Verify the menu item is visible
expect(env.component.isServalAdmin).toBeTruthy();
expect(env.userMenu).not.toBeNull();
expect(env.userMenu.query(By.css('#serval-admin-btn'))).not.toBeNull();

// Hide the user menu
env.avatarIcon.nativeElement.click();
env.wait();
}));

it('does not show system administration menu item', fakeAsync(() => {
const env = new TestEnvironment('online');
when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]);
env.init();

// Show the user menu
env.avatarIcon.nativeElement.click();
env.wait();

// Verify the menu item is not visible
expect(env.component.isSystemAdmin).toBeFalsy();
expect(env.userMenu).not.toBeNull();
expect(env.userMenu.query(By.css('#system-admin-btn'))).toBeNull();

// Hide the user menu
env.avatarIcon.nativeElement.click();
env.wait();
}));
});

describe('System Administrator', () => {
it('shows system administration menu item', fakeAsync(() => {
const env = new TestEnvironment('online');
when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.SystemAdmin]);
env.init();

// Show the user menu
env.avatarIcon.nativeElement.click();
env.wait();

// Verify the menu item is visible
expect(env.component.isSystemAdmin).toBeTruthy();
expect(env.userMenu).not.toBeNull();
expect(env.userMenu.query(By.css('#system-admin-btn'))).not.toBeNull();

// Hide the user menu
env.avatarIcon.nativeElement.click();
env.wait();
}));

it('does not show serval administration menu item', fakeAsync(() => {
const env = new TestEnvironment('online');
when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.SystemAdmin]);
env.init();

// Show the user menu
env.avatarIcon.nativeElement.click();
env.wait();

// Verify the menu item is not visible
expect(env.component.isServalAdmin).toBeFalsy();
expect(env.userMenu).not.toBeNull();
expect(env.userMenu.query(By.css('#serval-admin-btn'))).toBeNull();

// Hide the user menu
env.avatarIcon.nativeElement.click();
env.wait();
}));
});
});

class TestEnvironment {
Expand Down
4 changes: 4 additions & 0 deletions src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest
return this.noticeService.isAppLoading;
}

get isServalAdmin(): boolean {
return this.authService.currentUserRoles.includes(SystemRole.ServalAdmin);
}

get isSystemAdmin(): boolean {
return this.authService.currentUserRoles.includes(SystemRole.SystemAdmin);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role';
import { of } from 'rxjs';
import { anything, mock, when } from 'ts-mockito';
import { AuthGuard } from 'xforge-common/auth.guard';
import { AuthService } from 'xforge-common/auth.service';
import { configureTestingModule } from 'xforge-common/test-utils';
import { ServalAdminAuthGuard } from './serval-admin-auth.guard';

const mockedAuthGuard = mock(AuthGuard);
const mockedAuthService = mock(AuthService);

describe('ServalAdminAuthGuard', () => {
configureTestingModule(() => ({
providers: [
{ provide: AuthGuard, useMock: mockedAuthGuard },
{ provide: AuthService, useMock: mockedAuthService }
]
}));

it('can activate if user is logged in and has ServalAdmin role', () => {
const env = new TestEnvironment(true, SystemRole.ServalAdmin);

env.service.canActivate({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot).subscribe(result => {
expect(result).toBeTruthy();
});
});

it('cannot activate if user is not logged in', () => {
const env = new TestEnvironment(false, SystemRole.None);

env.service.canActivate({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot).subscribe(result => {
expect(result).toBeFalsy();
});
});

it('cannot activate if user is logged in but does not have ServalAdmin role', () => {
const env = new TestEnvironment(true, SystemRole.SystemAdmin);

env.service.canActivate({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot).subscribe(result => {
expect(result).toBeFalsy();
});
});

class TestEnvironment {
readonly service: ServalAdminAuthGuard;

constructor(isLoggedIn: boolean, role: SystemRole) {
this.service = TestBed.inject(ServalAdminAuthGuard);
when(mockedAuthGuard.canActivate(anything(), anything())).thenReturn(of(isLoggedIn));
when(mockedAuthGuard.allowTransition()).thenReturn(of(isLoggedIn));
when(mockedAuthService.currentUserRoles).thenReturn([role]);
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role';
import { Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { AuthGuard } from 'xforge-common/auth.guard';
import { AuthService } from 'xforge-common/auth.service';

@Injectable({
providedIn: 'root'
})
export class ServalAdminAuthGuard {
constructor(private readonly authGuard: AuthGuard, private readonly authService: AuthService) {}

canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
return this.authGuard.canActivate(next, state).pipe(switchMap(() => this.allowTransition()));
}

allowTransition(): Observable<boolean> {
return this.authGuard.allowTransition().pipe(
map(isLoggedIn => {
if (isLoggedIn) {
return this.authService.currentUserRoles.includes(SystemRole.ServalAdmin);
}
return false;
})
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div fxLayout="column" fxLayoutAlign="center start" class="body-content">
<h1>Serval Administration</h1>
</div>

<app-serval-projects></app-serval-projects>
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TestRealtimeModule } from 'xforge-common/test-realtime.module';
import { configureTestingModule, TestTranslocoModule } from 'xforge-common/test-utils';
import { SF_TYPE_REGISTRY } from '../core/models/sf-type-registry';
import { ServalAdministrationComponent } from './serval-administration.component';
import { ServalProjectsComponent } from './serval-projects.component';

describe('ServalAdministrationComponent', () => {
configureTestingModule(() => ({
imports: [
ServalAdministrationComponent,
ServalProjectsComponent,
NoopAnimationsModule,
TestTranslocoModule,
TestRealtimeModule.forRoot(SF_TYPE_REGISTRY),
HttpClientTestingModule
]
}));

it('should be created', () => {
const env = new TestEnvironment();
expect(env.component).toBeTruthy();
});

class TestEnvironment {
readonly component: ServalAdministrationComponent;
readonly fixture: ComponentFixture<ServalAdministrationComponent>;

constructor() {
this.fixture = TestBed.createComponent(ServalAdministrationComponent);
this.component = this.fixture.componentInstance;
this.fixture.detectChanges();
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { ServalProjectsComponent } from './serval-projects.component';

@Component({
selector: 'app-serval-administration',
templateUrl: './serval-administration.component.html',
styleUrls: ['./serval-administration.component.scss'],
standalone: true,
imports: [ServalProjectsComponent]
})
export class ServalAdministrationComponent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { TestBed } from '@angular/core/testing';
import { mock } from 'ts-mockito';
import { CommandService } from 'xforge-common/command.service';
import { RealtimeService } from 'xforge-common/realtime.service';
import { RetryingRequestService } from 'xforge-common/retrying-request.service';
import { configureTestingModule } from 'xforge-common/test-utils';
import { ServalAdministrationService } from './serval-administration.service';

const mockedCommandService = mock(CommandService);
const mockedRealtimeService = mock(RealtimeService);
const mockedRetryingRequestService = mock(RetryingRequestService);

describe('ServalAdministrationService', () => {
configureTestingModule(() => ({
providers: [
{ provide: CommandService, useMock: mockedCommandService },
{ provide: RealtimeService, useMock: mockedRealtimeService },
{ provide: RetryingRequestService, useMock: mockedRetryingRequestService }
]
}));

it('should be created', () => {
const env = new TestEnvironment();
expect(env.service).toBeTruthy();
});

class TestEnvironment {
readonly service: ServalAdministrationService;

constructor() {
this.service = TestBed.inject(ServalAdministrationService);
}
}
});
Loading

0 comments on commit 4ce3934

Please sign in to comment.