diff --git a/src/RealtimeServer/common/services/project-service.ts b/src/RealtimeServer/common/services/project-service.ts index f850e4e5e60..3f6b71f3656 100644 --- a/src/RealtimeServer/common/services/project-service.ts +++ b/src/RealtimeServer/common/services/project-service.ts @@ -65,18 +65,23 @@ export abstract class ProjectService extends JsonDo }; protected allowRead(_docId: string, doc: T, session: ConnectSession): boolean { - if ( - session.isServer || - session.roles.includes(SystemRole.ServalAdmin) || - session.roles.includes(SystemRole.SystemAdmin) || - Object.keys(doc).length === 0 - ) { + if (this.hasUnrestrictedReadAccess(doc, session)) { return true; } return doc.userRoles != null && session.userId in doc.userRoles; } + /** Whether the session may read any project doc regardless of project membership. */ + protected hasUnrestrictedReadAccess(doc: T, session: ConnectSession): boolean { + return ( + session.isServer || + session.roles.includes(SystemRole.ServalAdmin) || + session.roles.includes(SystemRole.SystemAdmin) || + Object.keys(doc).length === 0 + ); + } + protected allowUpdate(_docId: string, _oldDoc: T, newDoc: T, ops: any, session: ConnectSession): boolean { if (session.isServer || session.roles.includes(SystemRole.SystemAdmin)) { return true; diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-service.spec.ts b/src/RealtimeServer/scriptureforge/services/sf-project-service.spec.ts index c9483e12c2c..4a7756a5390 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-service.spec.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-service.spec.ts @@ -45,6 +45,15 @@ describe('SFProjectService', () => { await expect(fetchDoc(conn, env.collection, 'project01')).resolves.not.toThrow(); }); + it('allows serval admin user to see project', async () => { + const env = new TestEnvironment(); + expect(env.paratextUsers.find(u => u.sfUserId === 'serval_admin')).toBeUndefined(); + await env.createData(); + + const conn = clientConnect(env.server, 'serval_admin', SystemRole.ServalAdmin); + await expect(fetchDoc(conn, env.collection, 'project01')).resolves.not.toThrow(); + }); + it('does not allow non-member to view profile', async () => { const env = new TestEnvironment(); await env.createData(); diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-service.ts b/src/RealtimeServer/scriptureforge/services/sf-project-service.ts index 339465f739a..f70bd667266 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-service.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-service.ts @@ -2,7 +2,6 @@ import ShareDB from 'sharedb'; import { ConnectSession } from '../../common/connect-session'; import { MigrationConstructor } from '../../common/migration'; import { Operation } from '../../common/models/project-rights'; -import { SystemRole } from '../../common/models/system-role'; import { ValidationSchema } from '../../common/models/validation-schema'; import { RealtimeServer } from '../../common/realtime-server'; import { ProjectDomainConfig } from '../../common/services/project-data-service'; @@ -596,7 +595,7 @@ export class SFProjectService extends ProjectService { } protected allowRead(docId: string, doc: SFProject, session: ConnectSession): boolean { - if (session.isServer || session.roles.includes(SystemRole.SystemAdmin) || Object.keys(doc).length === 0) { + if (this.hasUnrestrictedReadAccess(doc, session)) { return true; } if (this.hasRight(session.userId, doc, Operation.View)) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html index 55e653e40b5..dfa376e77cc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html @@ -143,7 +143,7 @@ [disabled]="!isAppOnline" appRouterLink="/serval-administration" > - tune + engineering {{ t("serval_administration") }} } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts index 87cb5048db9..8cd270f430c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts @@ -380,6 +380,30 @@ describe('AppComponent', () => { verify(mockedDialogService.message(anything())).never(); })); + it('does not show the checking disabled message to a serval admin who is not on the project', fakeAsync(() => { + const env = new TestEnvironment(); + env.setCurrentUser('user05'); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); + env.navigate(['/serval-administration', 'project01']); + when(mockedLocationService.pathname).thenReturn('/serval-administration/project01'); + env.init(); + env.setCheckingEnabled('project01', false); + + expect(env.selectedProjectId).toEqual('project01'); + expect(env.component.showCheckingDisabled).toBe(false); + })); + + it('shows the checking disabled message to a community checker when checking is disabled', fakeAsync(() => { + const env = new TestEnvironment(); + env.setCurrentUser('user02'); + env.navigate(['/projects', 'project01']); + env.init(); + expect(env.component.showCheckingDisabled).toBe(false); + + env.setCheckingEnabled('project01', false); + expect(env.component.showCheckingDisabled).toBe(true); + })); + it('response to Commenter project role changed', fakeAsync(() => { const env = new TestEnvironment(); env.navigate(['/projects', 'project01']); @@ -993,6 +1017,12 @@ class TestEnvironment { this.wait(); } + setCheckingEnabled(projectId: string, enabled: boolean): void { + const projectDoc = this.realtimeService.get(SFProjectProfileDoc.COLLECTION, projectId); + projectDoc.submitJson0Op(op => op.set(p => p.checkingConfig.checkingEnabled, enabled), false); + this.wait(); + } + showHideUserMenu(): void { this.avatarIcon.nativeElement.click(); this.wait(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts index 5f6fb6dc3ae..d60e14ee5f1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts @@ -211,6 +211,7 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest this._selectedProjectDoc != null && this._selectedProjectDoc.data != null && !this._selectedProjectDoc.data.checkingConfig.checkingEnabled && + this.selectedProjectRole != null && !roleCanAccessTranslate(this.selectedProjectRole) ); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts index 5054ea539ec..650c2e22ab0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts @@ -29,6 +29,7 @@ import { TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-inf import { VerseRefData } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; import { of } from 'rxjs'; import { anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { AuthService } from 'xforge-common/auth.service'; import { DialogService } from 'xforge-common/dialog.service'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -60,6 +61,7 @@ const mockedQuestionsService = mock(CheckingQuestionsService); const mockedUserService = mock(UserService); const mockedQuestionDialogService = mock(QuestionDialogService); const mockedChapterAudioDialogService = mock(ChapterAudioDialogService); +const mockedAuthService = mock(AuthService); describe('CheckingOverviewComponent', () => { configureTestingModule(() => ({ @@ -75,7 +77,8 @@ describe('CheckingOverviewComponent', () => { { provide: UserService, useMock: mockedUserService }, { provide: QuestionDialogService, useMock: mockedQuestionDialogService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService }, - { provide: ChapterAudioDialogService, useMock: mockedChapterAudioDialogService } + { provide: ChapterAudioDialogService, useMock: mockedChapterAudioDialogService }, + { provide: AuthService, useMock: mockedAuthService } ] })); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts index 6619d3dc0df..fb00d2419fd 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts @@ -50,6 +50,7 @@ import { verify, when } from 'ts-mockito'; +import { AuthService } from 'xforge-common/auth.service'; import { DialogService } from 'xforge-common/dialog.service'; import { FileService } from 'xforge-common/file.service'; import { createStorageFileData, FileOfflineData, FileType } from 'xforge-common/models/file-offline-data'; @@ -110,6 +111,7 @@ const mockedDialogService = mock(DialogService); const mockedQuestionDialogService = mock(QuestionDialogService); const mockedChapterAudioDialogService = mock(ChapterAudioDialogService); const mockedFileService = mock(FileService); +const mockedAuthService = mock(AuthService); function createUser(idSuffix: number, role: string, nameConfirmed: boolean = true): UserInfo { return { @@ -182,6 +184,7 @@ describe('CheckingComponent', () => { { provide: ChapterAudioDialogService, useMock: mockedChapterAudioDialogService }, { provide: FileService, useMock: mockedFileService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService }, + { provide: AuthService, useMock: mockedAuthService }, provideNoopAnimations() ] })); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.spec.ts index 71b4d613bdf..2ecf3d11e7d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.spec.ts @@ -1,6 +1,7 @@ import { fakeAsync, TestBed } from '@angular/core/testing'; import { User } from '@bugsnag/js'; import { cloneDeep } from 'lodash-es'; +import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data'; import { RecursivePartial } from 'realtime-server/lib/esm/common/utils/type-utils'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; @@ -8,6 +9,7 @@ import { isParatextRole, SFProjectRole } from 'realtime-server/lib/esm/scripture import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { AuthService } from 'xforge-common/auth.service'; import { UserDoc } from 'xforge-common/models/user-doc'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; @@ -20,6 +22,7 @@ import { RESOURCE_IDENTIFIER_LENGTH } from './paratext.service'; import { PermissionsService } from './permissions.service'; import { SFProjectService } from './sf-project.service'; +const mockedAuthService = mock(AuthService); const mockedUserService = mock(UserService); const mockedProjectService = mock(SFProjectService); const mockedProjectDoc = mock(SFProjectProfileDoc); @@ -27,6 +30,7 @@ describe('PermissionsService', () => { configureTestingModule(() => ({ providers: [ provideTestRealtime(SF_TYPE_REGISTRY), + { provide: AuthService, useMock: mockedAuthService }, { provide: UserService, useMock: mockedUserService }, { provide: SFProjectService, useMock: mockedProjectService } ] @@ -162,6 +166,42 @@ describe('PermissionsService', () => { verify(mockedProjectService.getProfile('project01')).twice(); })); + describe('canInitiateSync', () => { + it('allows serval admins to sync resources they have read access to', () => { + const env = new TestEnvironment(); + env.setProjectType('resource'); + env.setCurrentUser(SFProjectRole.ParatextObserver); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); + expect(env.service.canInitiateSync(env.projectDoc)).toBe(true); + }); + + it('does not allow serval admins to sync non-resource projects they have read access to', () => { + const env = new TestEnvironment(); + env.setProjectType('project'); + env.setCurrentUser(SFProjectRole.ParatextObserver); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); + expect(env.service.canInitiateSync(env.projectDoc)).toBe(false); + }); + + it('does not allow non-serval-admins with only read access to sync resources', () => { + const env = new TestEnvironment(); + env.setProjectType('resource'); + env.setCurrentUser(SFProjectRole.ParatextObserver); + when(mockedAuthService.currentUserRoles).thenReturn([]); + expect(env.service.canInitiateSync(env.projectDoc)).toBe(false); + }); + }); + + describe('canAccessSync and canAccessProjectSettings', () => { + it('do not admit serval admins when the project doc has no data', () => { + const env = new TestEnvironment(); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); + when(mockedProjectDoc.data).thenReturn(undefined); + expect(env.service.canAccessSync(env.projectDoc)).toBe(false); + expect(env.service.canAccessProjectSettings(env.projectDoc)).toBe(false); + }); + }); + describe('canSync', () => { it('returns false when projectDoc.data is undefined', fakeAsync(() => { const env = new TestEnvironment(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts index 92c14a27586..33ebfe703f2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts @@ -1,10 +1,12 @@ import { Injectable } from '@angular/core'; import { Operation } from 'realtime-server/lib/esm/common/models/project-rights'; -import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; +import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; +import { isResource, SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights'; import { isParatextRole, SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; +import { AuthService } from 'xforge-common/auth.service'; import { UserDoc } from 'xforge-common/models/user-doc'; import { UserService } from 'xforge-common/user.service'; import { environment } from '../../environments/environment'; @@ -21,10 +23,19 @@ import { SFProjectService } from './sf-project.service'; @Injectable({ providedIn: 'root' }) export class PermissionsService { constructor( + private readonly authService: AuthService, private readonly userService: UserService, private readonly projectService: SFProjectService ) {} + get isServalAdmin(): boolean { + return this.authService.currentUserRoles.includes(SystemRole.ServalAdmin); + } + + get isSystemAdmin(): boolean { + return this.authService.currentUserRoles.includes(SystemRole.SystemAdmin); + } + canAccessCommunityChecking(project: SFProjectProfileDoc, userId?: string): boolean { if (project.data == null) return false; const role = project.data.userRoles[userId ?? this.userService.currentUserId]; @@ -104,6 +115,48 @@ export class PermissionsService { return false; } + /** Whether the user can open the project settings and users pages (serval admins get read-only access). */ + canAccessProjectSettings(projectDoc: SFProjectProfileDoc): boolean { + return projectDoc.data != null && (this.isServalAdmin || this.canEditProjectSettings(projectDoc)); + } + + /** Whether the user can open the sync page (serval admins get read-only access). */ + canAccessSync(projectDoc: SFProjectProfileDoc): boolean { + return projectDoc.data != null && (this.isServalAdmin || this.canInitiateSync(projectDoc)); + } + + /** Whether the user has the project role required to edit project settings and manage project users. */ + canEditProjectSettings(projectDoc: SFProjectProfileDoc, userId?: string): boolean { + return this.isProjectAdmin(projectDoc, userId); + } + + /** Whether the user can initiate a synchronization of the project. */ + canInitiateSync(projectDoc: SFProjectProfileDoc, userId?: string): boolean { + if (projectDoc.data == null) return false; + if ( + SF_PROJECT_RIGHTS.hasRight( + projectDoc.data, + userId ?? this.userService.currentUserId, + SFProjectDomain.Texts, + Operation.Edit + ) + ) { + return true; + } + // The backend allows anyone with any Paratext role to sync a DBL resource, so serval admins can sync resources + // they have read access to + return ( + this.isServalAdmin && + isResource(projectDoc.data) && + SF_PROJECT_RIGHTS.hasRight( + projectDoc.data, + userId ?? this.userService.currentUserId, + SFProjectDomain.Texts, + Operation.View + ) + ); + } + canSync(projectDoc: SFProjectProfileDoc, userId?: string): boolean { if (projectDoc.data == null) { return false; @@ -122,9 +175,13 @@ export class PermissionsService { /** Whether the user is allowed to configure drafting sources for the project. */ canConfigureSources(projectDoc?: SFProjectProfileDoc, userId?: string): boolean { + return this.isProjectAdmin(projectDoc, userId); + } + + /** Whether the user's role on the project is Paratext administrator. */ + private isProjectAdmin(projectDoc?: SFProjectProfileDoc, userId?: string): boolean { if (projectDoc?.data == null) return false; - const role = projectDoc.data.userRoles[userId ?? this.userService.currentUserId]; - return role === SFProjectRole.ParatextAdministrator; + return projectDoc.data.userRoles[userId ?? this.userService.currentUserId] === SFProjectRole.ParatextAdministrator; } canAccessBiblicalTerms(projectDoc: SFProjectProfileDoc): boolean { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts index 015bb232fe5..0dbade6c053 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts @@ -27,6 +27,7 @@ import { QueryParameters, QueryResults } from 'xforge-common/query-parameters'; import { RealtimeService } from 'xforge-common/realtime.service'; import { RetryingRequest, RetryingRequestService } from 'xforge-common/retrying-request.service'; import { EventMetric } from '../event-metrics/event-metric'; +import { SyncMetricsDisplay } from '../sync/sync-log/sync-metrics-display'; import { BookProgressWithChapterProgress } from '../shared/progress-service/progress.service'; import { expandNumbers } from '../shared/utils'; import { BiblicalTermDoc } from './models/biblical-term-doc'; @@ -432,6 +433,14 @@ export class SFProjectService extends ProjectService { return await this.onlineInvoke>('eventMetrics', { projectId, pageIndex, pageSize }); } + async onlineSyncMetrics( + projectId: string, + pageIndex: number, + pageSize: number + ): Promise> { + return await this.onlineInvoke>('syncMetrics', { projectId, pageIndex, pageSize }); + } + async onlineAllEventMetricsForConstructingDraftJobs( eventTypes: string[], projectId?: string, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.ts index d61a174afad..fbb8fd334ff 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.ts @@ -22,7 +22,6 @@ import { ActivatedProjectService } from 'xforge-common/activated-project.service import { AuthService } from 'xforge-common/auth.service'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; -import { I18nService } from 'xforge-common/i18n.service'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { OwnerComponent } from 'xforge-common/owner/owner.component'; @@ -34,6 +33,7 @@ interface Row { dialogData: EventMetric; eventType: string; scope: string; + /** The raw ISO timestamp of the event. OwnerComponent localizes it for display. */ timeStamp: string; userId?: string; successful: boolean; @@ -96,7 +96,6 @@ export class EventMetricsLogComponent extends DataLoadingComponent implements On private readonly activatedProjectService: ActivatedProjectService, private readonly authService: AuthService, private readonly dialogService: DialogService, - private readonly i18n: I18nService, private readonly onlineStatusService: OnlineStatusService, private readonly projectService: SFProjectService, private destroyRef: DestroyRef @@ -173,7 +172,7 @@ export class EventMetricsLogComponent extends DataLoadingComponent implements On eventType: this.getEventType(eventMetric), scope: eventMetric.scope, successful: eventMetric.exception == null, - timeStamp: this.i18n.formatDate(new Date(eventMetric.timeStamp), { showTimeZone: true }), + timeStamp: eventMetric.timeStamp, userId: eventMetric.userId }); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/my-projects/my-projects.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/my-projects/my-projects.component.spec.ts index 786e34a00f8..e7f0b85d91f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/my-projects/my-projects.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/my-projects/my-projects.component.spec.ts @@ -10,6 +10,7 @@ import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge import { createTestProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config-test-data'; import { BehaviorSubject, of } from 'rxjs'; import { anything, mock, verify, when } from 'ts-mockito'; +import { AuthService } from 'xforge-common/auth.service'; import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; @@ -36,6 +37,7 @@ const mockedUserService = mock(UserService); const mockedUserProjectsService = mock(SFUserProjectsService); const mockedParatextService = mock(ParatextService); const mockedNoticeService = mock(NoticeService); +const mockedAuthService = mock(AuthService); describe('MyProjectsComponent', () => { configureTestingModule(() => ({ @@ -52,7 +54,8 @@ describe('MyProjectsComponent', () => { { provide: ParatextService, useMock: mockedParatextService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService }, { provide: SFUserProjectsService, useMock: mockedUserProjectsService }, - { provide: NoticeService, useMock: mockedNoticeService } + { provide: NoticeService, useMock: mockedNoticeService }, + { provide: AuthService, useMock: mockedAuthService } ] })); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.html index a95f03af739..244ea07e5d1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.html @@ -71,6 +71,12 @@ {{ t("settings") }} } + @if (isServalAdmin && selectedProjectId != null) { + + engineering + {{ t("serval_administration") }} + + } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.spec.ts index b1031664330..75c919f0000 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.spec.ts @@ -2,9 +2,11 @@ import { DebugElement } from '@angular/core'; import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; +import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; import { BehaviorSubject, of, Subject } from 'rxjs'; import { anything, mock, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; import { createTestFeatureFlag, FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; import { I18nService } from 'xforge-common/i18n.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -15,6 +17,7 @@ import { UserService } from 'xforge-common/user.service'; import { ResumeCheckingService } from '../checking/checking/resume-checking.service'; import { ResumeTranslateService } from '../checking/checking/resume-translate.service'; import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc'; +import { SFProjectService } from '../core/sf-project.service'; import { NmtDraftAuthGuard, SettingsAuthGuard, SyncAuthGuard, UsersAuthGuard } from '../shared/project-router.guard'; import { NavigationComponent } from './navigation.component'; @@ -24,6 +27,8 @@ describe('NavigationComponent', () => { const mockedUsersAuthGuard = mock(UsersAuthGuard); const mockedNmtDraftAuthGuard = mock(NmtDraftAuthGuard); const mockedActivatedProjectService = mock(ActivatedProjectService); + const mockedAuthService = mock(AuthService); + const mockedProjectService = mock(SFProjectService); const mockedUserService = mock(UserService); const mockedResumeCheckingService = mock(ResumeCheckingService); const mockedResumeTranslateService = mock(ResumeTranslateService); @@ -41,6 +46,8 @@ describe('NavigationComponent', () => { { provide: UsersAuthGuard, useMock: mockedUsersAuthGuard }, { provide: NmtDraftAuthGuard, useMock: mockedNmtDraftAuthGuard }, { provide: ActivatedProjectService, useMock: mockedActivatedProjectService }, + { provide: AuthService, useMock: mockedAuthService }, + { provide: SFProjectService, useMock: mockedProjectService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService }, { provide: UserService, useMock: mockedUserService }, { provide: ResumeCheckingService, useMock: mockedResumeCheckingService }, @@ -70,6 +77,7 @@ describe('NavigationComponent', () => { when(mockedUsersAuthGuard.allowTransition(anything())).thenReturn(this.canSeeUsers$); when(mockedNmtDraftAuthGuard.allowTransition(anything())).thenReturn(this.canGenerateDraft$); when(mockedUserService.currentUserId).thenReturn('user01'); + when(mockedAuthService.currentUserRoles).thenReturn([]); when(mockedRouter.url).thenReturn('/projects/project01'); when(mockedRouter.createUrlTree(anything(), anything())).thenReturn([] as any); when(mockedRouter.serializeUrl(anything())).thenReturn(''); @@ -86,6 +94,10 @@ describe('NavigationComponent', () => { return this.fixture.debugElement.query(By.css('#admin-pages-menu-list')); } + get servalAdminNavItem(): DebugElement | null { + return this.fixture.debugElement.query(By.css('#serval-admin-nav-item')); + } + emitProjectChange(projectDoc: SFProjectProfileDoc | undefined): void { when(mockedActivatedProjectService.projectDoc).thenReturn(projectDoc); this.changes$.next(projectDoc); @@ -157,6 +169,28 @@ describe('NavigationComponent', () => { flush(); })); + it('shows the serval administration item for serval admins on a project', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); + // The guards return true for serval admins, so the admin pages section is shown + env.canSync$.next(true); + env.emitProjectChange({ id: 'project01' } as SFProjectProfileDoc); + + expect(env.servalAdminNavItem).not.toBeNull(); + expect(env.component.servalAdministrationLink).toEqual(['/serval-administration', 'project01']); + flush(); + })); + + it('hides the serval administration item for non serval admins', fakeAsync(() => { + const env = new TestEnvironment(); + env.canSync$.next(true); + env.emitProjectChange({ id: 'project01' } as SFProjectProfileDoc); + + expect(env.adminPagesList).not.toBeNull(); + expect(env.servalAdminNavItem).toBeNull(); + flush(); + })); + it('throttles rapid changes$ emissions to one leading and one trailing call per window', fakeAsync(() => { const env = new TestEnvironment(); const projectDoc = { id: 'project01' } as SFProjectProfileDoc; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.ts index 590887c24bf..708641cda4a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.ts @@ -21,6 +21,7 @@ import { ResumeCheckingService } from '../checking/checking/resume-checking.serv import { ResumeTranslateService } from '../checking/checking/resume-translate.service'; import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc'; import { roleCanAccessCommunityChecking, roleCanAccessTranslate } from '../core/models/sf-project-role-info'; +import { PermissionsService } from '../core/permissions.service'; import { NmtDraftAuthGuard, SettingsAuthGuard, SyncAuthGuard, UsersAuthGuard } from '../shared/project-router.guard'; @Component({ @@ -71,9 +72,18 @@ export class NavigationComponent { private readonly resumeTranslateService: ResumeTranslateService, private readonly router: Router, private readonly activatedProjectService: ActivatedProjectService, + private readonly permissionsService: PermissionsService, readonly featureFlags: FeatureFlagService ) {} + get isServalAdmin(): boolean { + return this.permissionsService.isServalAdmin; + } + + get servalAdministrationLink(): string[] { + return this.selectedProjectId == null ? [] : ['/serval-administration', this.selectedProjectId]; + } + get selectedProjectDoc(): SFProjectProfileDoc | undefined { return this.activatedProjectService.projectDoc; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts index 7511f455743..ce38cb16c1e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts @@ -33,7 +33,6 @@ const mockedAuthService = mock(AuthService); const mockedActivatedRoute = mock(ActivatedRoute); const mockedFeatureFlagService = mock(FeatureFlagService); const mockedRouter = mock(Router); -const mockedPermissionsService = mock(PermissionsService); const mockedResumeCheckingService = mock(ResumeCheckingService); const mockedResumeTranslateService = mock(ResumeTranslateService); let testActivatedProjectService: ActivatedProjectService; @@ -54,6 +53,7 @@ function setUpMocks(args: StoryState): void { }); when(mockedAuthService.isLoggedIn).thenResolve(true); + when(mockedAuthService.currentUserRoles).thenReturn([]); when(mockedUserService.currentUserId).thenReturn(userId); when(onlineStatusService.isOnline).thenReturn(args.online); when(mockedFeatureFlagService.stillness).thenReturn(createTestFeatureFlag(false)); @@ -134,10 +134,8 @@ const meta: Meta = { provide: ResumeTranslateService, useValue: instance(mockedResumeTranslateService) }, - { - provide: PermissionsService, - useValue: instance(mockedPermissionsService) - }, + // Use a real PermissionsService so the role permission logic the guards rely on is exercised + { provide: PermissionsService, useClass: PermissionsService }, { provide: ActivatedProjectService, useValue: testActivatedProjectService diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.spec.ts index 0476b2e99d2..b8bd5f8a38e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.spec.ts @@ -15,6 +15,7 @@ import { import { createTestProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config-test-data'; import { of } from 'rxjs'; import { anything, deepEqual, mock, verify, when } from 'ts-mockito'; +import { AuthService } from 'xforge-common/auth.service'; import { DialogService } from 'xforge-common/dialog.service'; import { UserDoc } from 'xforge-common/models/user-doc'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; @@ -27,6 +28,7 @@ import { SFProjectUserConfigDoc } from '../core/models/sf-project-user-config-do import { SF_TYPE_REGISTRY } from '../core/models/sf-type-registry'; import { ProjectComponent } from './project.component'; +const mockedAuthService = mock(AuthService); const mockedUserService = mock(UserService); const mockedActivatedRoute = mock(ActivatedRoute); const mockedRouter = mock(Router); @@ -38,6 +40,7 @@ describe('ProjectComponent', () => { configureTestingModule(() => ({ providers: [ provideTestRealtime(SF_TYPE_REGISTRY), + { provide: AuthService, useMock: mockedAuthService }, { provide: UserService, useMock: mockedUserService }, { provide: ActivatedRoute, useMock: mockedActivatedRoute }, { provide: Router, useMock: mockedRouter }, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.html index c206af99db5..4c12df371f8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.html @@ -3,6 +3,8 @@

{{ t("settings") }}

@if (!isAppOnline) { {{ t("connect_network_to_change_settings") }} + } @else if (isServalAdminReadOnly) { + }
@@ -11,7 +13,7 @@

{{ t("settings") }}

{{ t("translate") }}
- @if (!isLoadingData && !isLoggedInToParatext && isAppOnline) { + @if (!isLoadingData && !isLoggedInToParatext && isAppOnline && !isServalAdminReadOnly) { } @if (mainSettingsLoaded) { @@ -25,7 +27,9 @@

{{ t("settings") }}

[resources]="resources" [nonSelectableProjects]="nonSelectableProjects" [hiddenParatextIds]="projectParatextId ? [projectParatextId] : []" - [isDisabled]="!isAppOnline || isLoadingData || (projectLoadingFailed && resourceLoadingFailed)" + [isDisabled]=" + !isAppOnline || isLoadingData || !hasEditRights || (projectLoadingFailed && resourceLoadingFailed) + " > { expect(env.component.form.enabled).toBe(true); })); + it('is read-only for a serval admin who is not on the project', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); + when(mockedUserService.currentUserId).thenReturn('serval01'); + env.setupProject(); + env.wait(); + + expect(env.component.form.disabled).toBe(true); + expect(env.servalAdminReadOnlyNotice).not.toBeNull(); + expect(env.offlineMessage).toBeNull(); + expect(env.deleteProjectButton.disabled).toBe(true); + verify(mockedParatextService.getProjects()).never(); + verify(mockedParatextService.getResources()).never(); + verify(mockedAuthService.requestParatextCredentialUpdate()).never(); + })); + + it('does not let a read-only serval admin change the source text', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); + when(mockedUserService.currentUserId).thenReturn('serval01'); + env.setupProject(); + env.wait(); + + // The project select does not implement setDisabledState, so disabling the form does not disable it. It must be + // disabled explicitly, or the user could clear the source text and trigger an update the backend will reject. + expect(env.basedOnSelectComponent.isDisabled).toBe(true); + expect(env.inputElement(env.basedOnSelect).disabled).toBe(true); + })); + + it('is editable for a serval admin who is also a project admin', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); + env.setupProject(); + env.wait(); + + expect(env.component.form.enabled).toBe(true); + expect(env.servalAdminReadOnlyNotice).toBeNull(); + verify(mockedParatextService.getProjects()).once(); + verify(mockedParatextService.getResources()).once(); + })); + it('enables form even when projects and resources fail to load', fakeAsync(() => { const env = new TestEnvironment(); expect(env.component.form.disabled).toBe(true); @@ -764,6 +806,7 @@ class TestEnvironment { constructor(hasConnection: boolean = true, isSource: boolean = false) { when(mockedActivatedRoute.params).thenReturn(of({ projectId: 'project01' })); when(mockedAuthService.currentUserRoles).thenReturn([]); + when(mockedUserService.currentUserId).thenReturn('user01'); when(mockedFeatureFlagService.showDeveloperTools).thenReturn(createTestFeatureFlag(false)); when(mockedSFProjectService.onlineIsSourceProject('project01')).thenResolve(isSource); when(mockedSFProjectService.onlineDelete(anything())).thenResolve(); @@ -911,6 +954,10 @@ class TestEnvironment { return this.fixture.nativeElement.querySelector('.offline-text'); } + get servalAdminReadOnlyNotice(): HTMLElement { + return this.fixture.nativeElement.querySelector('#serval-admin-read-only-notice'); + } + get basedOnSelectErrorMessage(): HTMLElement { return this.fixture.nativeElement.querySelector('.tool-setting-field + mat-error'); } @@ -1029,6 +1076,7 @@ class TestEnvironment { } testProject: SFProject = createTestProject({ + userRoles: { user01: SFProjectRole.ParatextAdministrator }, translateConfig: { source: { paratextId: 'paratextId01', diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.ts index 56bc798ff29..759dc359b41 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.ts @@ -38,10 +38,12 @@ import { SelectableProject } from '../core/models/selectable-project'; import { SFProjectDoc } from '../core/models/sf-project-doc'; import { SFProjectSettings } from '../core/models/sf-project-settings'; import { ParatextService } from '../core/paratext.service'; +import { PermissionsService } from '../core/permissions.service'; import { SFProjectService } from '../core/sf-project.service'; import { ProjectSelectComponent } from '../project-select/project-select.component'; import { InfoComponent } from '../shared/info/info.component'; import { ParatextAccountNoticeComponent } from '../shared/paratext-account-notice/paratext-account-notice.component'; +import { ServalAdminReadOnlyNoticeComponent } from '../shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component'; import { DeleteProjectDialogComponent } from './delete-project-dialog/delete-project-dialog.component'; /** Allows user to configure high-level settings of how SF will use their Paratext project. */ @@ -69,7 +71,8 @@ import { DeleteProjectDialogComponent } from './delete-project-dialog/delete-pro TranslocoMarkupComponent, RouterLinkDirective, MatCardActions, - ParatextAccountNoticeComponent + ParatextAccountNoticeComponent, + ServalAdminReadOnlyNoticeComponent ] }) export class SettingsComponent extends DataLoadingComponent implements OnInit { @@ -142,6 +145,7 @@ export class SettingsComponent extends DataLoadingComponent implements OnInit { readonly authService: AuthService, readonly externalUrls: ExternalUrlService, private readonly activatedProjectService: ActivatedProjectService, + private readonly permissionsService: PermissionsService, private destroyRef: DestroyRef ) { super(noticeService, 'SettingsComponent'); @@ -194,7 +198,23 @@ export class SettingsComponent extends DataLoadingComponent implements OnInit { } get deleteButtonDisabled(): boolean { - return !this.isAppOnline || !this.mainSettingsLoaded || this.isActiveSourceProject || this.isProjectSyncing; + return ( + !this.isAppOnline || + !this.mainSettingsLoaded || + this.isActiveSourceProject || + this.isProjectSyncing || + !this.hasEditRights + ); + } + + /** Whether the user has the project role required to change settings. */ + get hasEditRights(): boolean { + return this.projectDoc != null && this.permissionsService.canEditProjectSettings(this.projectDoc); + } + + /** Whether the page is read-only because the user is only here by virtue of being a serval admin. */ + get isServalAdminReadOnly(): boolean { + return !this.hasEditRights && this.permissionsService.isServalAdmin; } ngOnInit(): void { @@ -210,9 +230,13 @@ export class SettingsComponent extends DataLoadingComponent implements OnInit { .pipe(quietTakeUntilDestroyed(this.destroyRef)) .subscribe(async ([isOnline, projectId]) => { this.isAppOnline = isOnline; - if (isOnline && this.projects == null) { + const readOnlySettingsLoaded: boolean = this.mainSettingsLoaded && !this.hasEditRights; + if (isOnline && this.projects == null && !readOnlySettingsLoaded) { this.loading = true; + const projectDocPromise = this.projectService + .get(projectId) + .then(projectDoc => (this.projectDoc = projectDoc)); const mainSettingsPromise = Promise.all([ this.projectService .onlineIsSourceProject(projectId) @@ -220,7 +244,7 @@ export class SettingsComponent extends DataLoadingComponent implements OnInit { firstValueFrom(this.paratextService.getParatextUsername()).then((username: string | undefined) => { if (username != null) this.paratextUsername = username; }), - this.projectService.get(projectId).then(projectDoc => (this.projectDoc = projectDoc)) + projectDocPromise ]).then(() => { if (this.projectDoc != null) { this.updateSettingsInfo(); @@ -234,6 +258,16 @@ export class SettingsComponent extends DataLoadingComponent implements OnInit { } }); + // Users without an admin role on the project (e.g. serval admins) cannot list Paratext projects and + // resources, and must not be prompted to update Paratext credentials, so skip those requests once rights + // are known. + await projectDocPromise; + if (!this.hasEditRights) { + await mainSettingsPromise; + this.loading = false; + return; + } + let paratextTokensExpired = false; const projectsAndResourcesPromise = Promise.all([ this.paratextService @@ -275,7 +309,7 @@ export class SettingsComponent extends DataLoadingComponent implements OnInit { } openDeleteProjectDialog(): void { - if (this.projectDoc == null || this.projectDoc.data == null) { + if (this.projectDoc == null || this.projectDoc.data == null || !this.hasEditRights) { return; } @@ -298,7 +332,7 @@ export class SettingsComponent extends DataLoadingComponent implements OnInit { } updateFormEnabled(): void { - if (this._isAppOnline && this.mainSettingsLoaded) { + if (this._isAppOnline && this.mainSettingsLoaded && this.hasEditRights) { this.form.enable(); this.setIndividualControlDisabledStates(); } else { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.spec.ts index 71590e08203..a2c8a424ad4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.spec.ts @@ -1,3 +1,4 @@ +import { Type } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; @@ -9,7 +10,7 @@ import { configureTestingModule } from 'xforge-common/test-utils'; import { UserService } from 'xforge-common/user.service'; import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc'; import { SFProjectService } from '../core/sf-project.service'; -import { DraftNavigationAuthGuard, SyncAuthGuard } from './project-router.guard'; +import { DraftNavigationAuthGuard, SettingsAuthGuard, SyncAuthGuard, UsersAuthGuard } from './project-router.guard'; const mockedAuthGuard = mock(AuthGuard); const mockedAuthService = mock(AuthService); @@ -49,7 +50,7 @@ describe('SyncAuthGuard', () => { it('administrators can access sync', async () => { // navigate away - const env = new SyncAuthGuardTestEnvironment(false); + const env = new GuardTestEnvironment(SyncAuthGuard, false); expect( env.service.check({ data: createTestProjectProfile({ userRoles: { user01: SFProjectRole.ParatextAdministrator } }) @@ -59,7 +60,7 @@ describe('SyncAuthGuard', () => { it('translators can access sync', async () => { // navigate away - const env = new SyncAuthGuardTestEnvironment(false); + const env = new GuardTestEnvironment(SyncAuthGuard, false); expect( env.service.check({ data: createTestProjectProfile({ userRoles: { user01: SFProjectRole.ParatextTranslator } }) @@ -69,7 +70,7 @@ describe('SyncAuthGuard', () => { it('consultants cannot access sync', async () => { // navigate away - const env = new SyncAuthGuardTestEnvironment(false); + const env = new GuardTestEnvironment(SyncAuthGuard, false); expect( env.service.check({ data: createTestProjectProfile({ userRoles: { user01: SFProjectRole.ParatextConsultant } }) @@ -77,9 +78,8 @@ describe('SyncAuthGuard', () => { ).toBe(false); }); - it('serval administrators can sync resources they have read access to', async () => { - // navigate away - const env = new SyncAuthGuardTestEnvironment(true); + it('serval administrators can access sync on resources they have read access to', async () => { + const env = new GuardTestEnvironment(SyncAuthGuard, true); expect( env.service.check({ data: createTestProjectProfile({ @@ -90,19 +90,57 @@ describe('SyncAuthGuard', () => { ).toBe(true); }); - it('serval administrators cannot sync projects they have read access to', async () => { - // navigate away - const env = new SyncAuthGuardTestEnvironment(true); + it('serval administrators can access sync on projects they are not a member of', async () => { + const env = new GuardTestEnvironment(SyncAuthGuard, true); expect( env.service.check({ - data: createTestProjectProfile({ - userRoles: { user01: SFProjectRole.ParatextObserver } - }) + data: createTestProjectProfile({ userRoles: {} }) } as SFProjectProfileDoc) - ).toBe(false); + ).toBe(true); }); }); +// SettingsAuthGuard and UsersAuthGuard share the same access rule +for (const guardType of [SettingsAuthGuard, UsersAuthGuard]) { + describe(guardType.name, () => { + configureTestingModule(() => ({ + providers: [ + { provide: AuthGuard, useMock: mockedAuthGuard }, + { provide: AuthService, useMock: mockedAuthService }, + { provide: SFProjectService, useMock: mockedProjectService }, + { provide: UserService, useMock: mockedUserService } + ] + })); + + it('administrators can access', async () => { + const env = new GuardTestEnvironment(guardType, false); + expect( + env.service.check({ + data: createTestProjectProfile({ userRoles: { user01: SFProjectRole.ParatextAdministrator } }) + } as SFProjectProfileDoc) + ).toBe(true); + }); + + it('translators cannot access', async () => { + const env = new GuardTestEnvironment(guardType, false); + expect( + env.service.check({ + data: createTestProjectProfile({ userRoles: { user01: SFProjectRole.ParatextTranslator } }) + } as SFProjectProfileDoc) + ).toBe(false); + }); + + it('serval administrators can access on projects they are not a member of', async () => { + const env = new GuardTestEnvironment(guardType, true); + expect( + env.service.check({ + data: createTestProjectProfile({ userRoles: {} }) + } as SFProjectProfileDoc) + ).toBe(true); + }); + }); +} + class DraftNavigationTestEnvironment { service: DraftNavigationAuthGuard; constructor() { @@ -110,15 +148,11 @@ class DraftNavigationTestEnvironment { } } -class SyncAuthGuardTestEnvironment { - service: SyncAuthGuard; - constructor(servalAdmin: boolean) { - this.service = TestBed.inject(SyncAuthGuard); +class GuardTestEnvironment { + service: T; + constructor(guardType: Type, servalAdmin: boolean) { + this.service = TestBed.inject(guardType); when(mockedUserService.currentUserId).thenReturn('user01'); - if (servalAdmin) { - when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); - } else { - when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.User]); - } + when(mockedAuthService.currentUserRoles).thenReturn(servalAdmin ? [SystemRole.ServalAdmin] : [SystemRole.User]); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.ts index e7e8eb82115..24f15434c4d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/project-router.guard.ts @@ -1,14 +1,10 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanDeactivate, Router, RouterStateSnapshot } from '@angular/router'; import { Operation } from 'realtime-server/lib/esm/common/models/project-rights'; -import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; -import { isResource } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights'; -import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { from, Observable, of } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; import { AuthGuard } from 'xforge-common/auth.guard'; -import { AuthService } from 'xforge-common/auth.service'; import { UserService } from 'xforge-common/user.service'; import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc'; import { PermissionsService } from '../core/permissions.service'; @@ -46,38 +42,20 @@ export class SettingsAuthGuard extends RouterGuard { constructor( authGuard: AuthGuard, projectService: SFProjectService, - private userService: UserService + private readonly permissionsService: PermissionsService ) { super(authGuard, projectService); } check(projectDoc: SFProjectProfileDoc): boolean { - return ( - projectDoc.data != null && - projectDoc.data.userRoles[this.userService.currentUserId] === SFProjectRole.ParatextAdministrator - ); + return this.permissionsService.canAccessProjectSettings(projectDoc); } } @Injectable({ providedIn: 'root' }) -export class UsersAuthGuard extends RouterGuard { - constructor( - authGuard: AuthGuard, - projectService: SFProjectService, - private userService: UserService - ) { - super(authGuard, projectService); - } - - check(projectDoc: SFProjectProfileDoc): boolean { - return ( - projectDoc.data != null && - projectDoc.data.userRoles[this.userService.currentUserId] === SFProjectRole.ParatextAdministrator - ); - } -} +export class UsersAuthGuard extends SettingsAuthGuard {} @Injectable({ providedIn: 'root' @@ -86,30 +64,13 @@ export class SyncAuthGuard extends RouterGuard { constructor( authGuard: AuthGuard, projectService: SFProjectService, - private readonly userService: UserService, - private readonly authService: AuthService + private readonly permissionsService: PermissionsService ) { super(authGuard, projectService); } check(projectDoc: SFProjectProfileDoc): boolean { - if (projectDoc.data == null) return false; - return ( - SF_PROJECT_RIGHTS.hasRight( - projectDoc.data, - this.userService.currentUserId, - SFProjectDomain.Texts, - Operation.Edit - ) || - (this.authService.currentUserRoles.includes(SystemRole.ServalAdmin) && - isResource(projectDoc.data) && - SF_PROJECT_RIGHTS.hasRight( - projectDoc.data, - this.userService.currentUserId, - SFProjectDomain.Texts, - Operation.View - )) - ); + return this.permissionsService.canAccessSync(projectDoc); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component.html new file mode 100644 index 00000000000..dfc306feddd --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component.html @@ -0,0 +1,3 @@ + + This page is read-only because you are viewing it as a Serval administrator. + diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component.scss new file mode 100644 index 00000000000..4dcdee7dff6 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component.scss @@ -0,0 +1,4 @@ +:host { + display: block; + margin-bottom: 16px; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component.ts new file mode 100644 index 00000000000..2bbdcd7b4cb --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { NoticeComponent } from '../notice/notice.component'; + +/** + * Notice shown on project admin pages that are read-only because the user is only there as a serval admin. + * Deliberately not localized: only serval admins ever see it. + */ +@Component({ + selector: 'app-serval-admin-read-only-notice', + templateUrl: './serval-admin-read-only-notice.component.html', + styleUrls: ['./serval-admin-read-only-notice.component.scss'], + imports: [NoticeComponent] +}) +export class ServalAdminReadOnlyNoticeComponent {} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.html new file mode 100644 index 00000000000..2d83e705524 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.html @@ -0,0 +1,48 @@ + +

{{ t("sync_history") }}

+ @if (!isOnline) { +
{{ t("offline") }}
+ } @else if (isLoading) { + + } @else if (rows.length === 0) { +
{{ t("no_syncs") }}
+ } @else { +
+ @for (row of rows; track row.syncMetrics.id) { + @let showErrorDetails = canSeeErrorDetails && row.syncMetrics.errorDetails != null; +
+
+ + {{ row.statusIcon }} + +
+ {{ t(row.statusKey) }} + +
+ @if (showErrorDetails) { + + } +
+ @if (showErrorDetails && isErrorExpanded(row.syncMetrics.id)) { +
+ +
{{ row.syncMetrics.errorDetails }}
+
+ } +
+ } +
+ @if (canShowMore) { + + } + } +
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.scss new file mode 100644 index 00000000000..d4790e981da --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.scss @@ -0,0 +1,78 @@ +@use 'src/variables' as sfColors; + +h2 { + margin-block-start: 32px; +} + +.sync-log-entries { + display: flex; + flex-direction: column; +} + +.sync-log-entry { + padding-block: 12px; + + &:not(:last-child) { + border-bottom: 1px solid var(--mat-sys-outline-variant); + } +} + +.entry-summary { + display: flex; + align-items: center; + gap: 12px; +} + +.entry-details { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.entry-status { + min-width: 80px; +} + +.status-icon.status-successful { + color: sfColors.$greenDark; +} + +.status-icon.status-failed { + color: sfColors.$red; +} + +.status-icon.status-running { + color: sfColors.$orange; +} + +.status-icon.status-queued, +.status-icon.status-cancelled { + color: sfColors.$greyLight; +} + +.error-details-button { + white-space: nowrap; +} + +.error-details { + display: flex; + align-items: flex-start; + gap: 8px; + margin-block-start: 8px; + + pre { + flex: 1; + margin: 0; + white-space: pre-wrap; + word-break: break-word; + } +} + +app-owner { + white-space: nowrap; +} + +.sync-log-label { + padding-top: 8px; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.spec.ts new file mode 100644 index 00000000000..de6d6d0651f --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.spec.ts @@ -0,0 +1,340 @@ +import { DebugElement } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; +import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; +import { BehaviorSubject } from 'rxjs'; +import { anything, mock, verify, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { QueryResults } from 'xforge-common/query-parameters'; +import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; +import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; +import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; +import { UserService } from 'xforge-common/user.service'; +import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; +import { SFProjectService } from '../../core/sf-project.service'; +import { SyncLogComponent } from './sync-log.component'; +import { SyncMetricsDisplay, SyncMetricsStatus } from './sync-metrics-display'; + +const dateQueued = '2026-08-11T20:50:00.000Z'; +const dateStarted = '2026-08-11T20:52:00.000Z'; +const dateFinished = '2026-08-11T20:55:00.000Z'; + +const mockedActivatedProjectService = mock(ActivatedProjectService); +const mockedAuthService = mock(AuthService); +const mockedProjectService = mock(SFProjectService); +const mockedUserService = mock(UserService); + +describe('SyncLogComponent', () => { + configureTestingModule(() => ({ + imports: [getTestTranslocoModule()], + providers: [ + provideTestOnlineStatus(), + { provide: AuthService, useMock: mockedAuthService }, + { provide: ActivatedProjectService, useMock: mockedActivatedProjectService }, + { provide: OnlineStatusService, useClass: TestOnlineStatusService }, + { provide: SFProjectService, useMock: mockedProjectService }, + { provide: UserService, useMock: mockedUserService } + ] + })); + + it('should display sync log entries', fakeAsync(() => { + const env = new TestEnvironment(); + env.populateSyncMetrics(); + env.wait(); + env.wait(); + + expect(env.entries.length).toEqual(3); + expect(env.emptyLabel).toBeNull(); + })); + + it('should give the owner component the raw date, so that it is only localized once', fakeAsync(() => { + const env = new TestEnvironment(); + env.populateSyncMetrics(); + env.wait(); + env.wait(); + + // A pre-formatted date would be re-parsed by the owner component, which swaps the day and month or yields an + // invalid date in many locales + expect(env.component.rows.map(row => row.dateTime)).toEqual([dateStarted, dateStarted, dateStarted]); + const expected = env.i18n.formatDate(new Date(dateStarted), { showTimeZone: true }); + expect(env.dateTimes).toEqual([expected, expected, expected]); + })); + + it('should show the queued date when a sync has not started', fakeAsync(() => { + const env = new TestEnvironment(); + env.setSyncMetrics([{ id: 'syncMetrics01', dateQueued, status: SyncMetricsStatus.Queued, userRef: 'user01' }]); + env.wait(); + env.wait(); + + expect(env.component.rows[0].dateTime).toEqual(dateQueued); + expect(env.dateTimes).toEqual([env.i18n.formatDate(new Date(dateQueued), { showTimeZone: true })]); + })); + + it('should show more entries when the show more button is clicked', fakeAsync(() => { + const env = new TestEnvironment(); + env.populateSyncMetrics({ unpagedCount: 20 }); + env.wait(); + env.wait(); + + expect(env.showMoreButton).not.toBeNull(); + verify(mockedProjectService.onlineSyncMetrics(env.mockProjectId, 0, 5)).once(); + + env.clickButton(env.showMoreButton); + env.wait(); + verify(mockedProjectService.onlineSyncMetrics(env.mockProjectId, 0, 15)).once(); + })); + + it('should hide the show more button when all entries are shown', fakeAsync(() => { + const env = new TestEnvironment(); + env.populateSyncMetrics({ unpagedCount: 3 }); + env.wait(); + env.wait(); + + expect(env.entries.length).toEqual(3); + expect(env.showMoreButton).toBeNull(); + })); + + it('should expand and collapse the error details for serval admins', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); + env.populateSyncMetrics(); + env.wait(); + env.wait(); + + // Only the failed sync with error details has a button + expect(env.errorDetailsButtons.length).toEqual(1); + expect(env.expandedErrorDetails.length).toEqual(0); + + env.clickButton(env.errorDetailsButtons[0]); + expect(env.expandedErrorDetails.length).toEqual(1); + expect(env.expandedErrorDetails[0].nativeElement.textContent).toContain('An error occurred'); + + env.clickButton(env.errorDetailsButtons[0]); + expect(env.expandedErrorDetails.length).toEqual(0); + })); + + it('should show the error details button to system admins', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.SystemAdmin]); + env.populateSyncMetrics(); + env.wait(); + env.wait(); + + expect(env.errorDetailsButtons.length).toEqual(1); + })); + + it('should not show the error details button to other users', fakeAsync(() => { + const env = new TestEnvironment(); + env.populateSyncMetrics(); + env.wait(); + env.wait(); + + expect(env.entries.length).toEqual(3); + expect(env.errorDetailsButtons.length).toEqual(0); + })); + + it('should show the empty label when there are no syncs', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).thenResolve({ + results: [], + unpagedCount: 0 + } as QueryResults); + env.wait(); + env.wait(); + + expect(env.entries.length).toEqual(0); + expect(env.emptyLabel).not.toBeNull(); + })); + + it('should refetch the log when a sync is queued or finishes', fakeAsync(() => { + const env = new TestEnvironment(); + env.populateSyncMetrics(); + env.wait(); + env.wait(); + verify(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).once(); + + // A sync is queued + env.setQueuedCount(1); + verify(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).twice(); + + // The sync finishes + env.setQueuedCount(0); + verify(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).thrice(); + + // An unrelated project document change does not refetch + env.setQueuedCount(0); + verify(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).thrice(); + })); + + it('should keep refetching after a failed fetch', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).thenReject( + new Error('Connection lost') + ); + env.wait(); + env.wait(); + expect(env.entries.length).toEqual(0); + + // The next refetch trigger succeeds + env.populateSyncMetrics(); + env.setQueuedCount(1); + env.wait(); + expect(env.entries.length).toEqual(3); + })); + + it('should discard a stale response that resolves after a newer fetch', fakeAsync(() => { + const env = new TestEnvironment(); + let resolveFirstFetch!: (value: QueryResults) => void; + when(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).thenReturn( + new Promise(resolve => (resolveFirstFetch = resolve)) + ); + env.wait(); + env.wait(); + + // A newer fetch is triggered and resolves while the first is still pending + env.populateSyncMetrics(); + env.setQueuedCount(1); + env.wait(); + expect(env.entries.length).toEqual(3); + + // The first response resolves last and must not overwrite the newer results + resolveFirstFetch({ results: [], unpagedCount: 0 } as QueryResults); + env.wait(); + expect(env.entries.length).toEqual(3); + })); + + it('should not fetch sync metrics if offline', fakeAsync(() => { + const env = new TestEnvironment(); + env.populateSyncMetrics(); + env.setBrowserOnlineStatus(false); + env.wait(); + env.wait(); + + verify(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).never(); + expect(env.offlineLabel).not.toBeNull(); + + env.setBrowserOnlineStatus(true); + env.wait(); + env.wait(); + verify(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).once(); + expect(env.offlineLabel).toBeNull(); + expect(env.entries.length).toEqual(3); + })); +}); + +class TestEnvironment { + readonly component: SyncLogComponent; + readonly fixture: ComponentFixture; + readonly testOnlineStatusService: TestOnlineStatusService = TestBed.inject( + OnlineStatusService + ) as TestOnlineStatusService; + readonly i18n: I18nService = TestBed.inject(I18nService); + + mockProjectId = 'project01'; + readonly projectDocChanges$ = new BehaviorSubject(undefined); + + constructor() { + const mockProjectId$ = new BehaviorSubject(this.mockProjectId); + when(mockedActivatedProjectService.projectId).thenReturn(this.mockProjectId); + when(mockedActivatedProjectService.projectId$).thenReturn(mockProjectId$); + when(mockedActivatedProjectService.changes$).thenReturn(this.projectDocChanges$); + when(mockedUserService.currentUserId).thenReturn('user01'); + when(mockedAuthService.currentUserRoles).thenReturn([]); + when(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).thenResolve({} as any); + + this.fixture = TestBed.createComponent(SyncLogComponent); + this.component = this.fixture.componentInstance; + } + + get entries(): DebugElement[] { + return this.fixture.debugElement.queryAll(By.css('.sync-log-entry')); + } + + get dateTimes(): string[] { + return this.fixture.debugElement + .queryAll(By.css('.sync-log-entry .date-time')) + .map(element => element.nativeElement.textContent.trim()); + } + + get errorDetailsButtons(): DebugElement[] { + return this.fixture.debugElement.queryAll(By.css('.error-details-button')); + } + + get expandedErrorDetails(): DebugElement[] { + return this.fixture.debugElement.queryAll(By.css('.sync-error-details')); + } + + get showMoreButton(): DebugElement { + return this.fixture.debugElement.query(By.css('#sync-log-show-more')); + } + + get emptyLabel(): DebugElement { + return this.fixture.debugElement.query(By.css('#sync-log-empty')); + } + + get offlineLabel(): DebugElement { + return this.fixture.debugElement.query(By.css('#sync-log-offline')); + } + + clickButton(button: DebugElement): void { + button.nativeElement.click(); + this.wait(); + } + + setQueuedCount(queuedCount: number): void { + this.projectDocChanges$.next({ + data: createTestProjectProfile({ sync: { queuedCount: queuedCount } }) + } as SFProjectProfileDoc); + this.wait(); + } + + populateSyncMetrics(args: { unpagedCount?: number } = {}): void { + const syncMetrics: SyncMetricsDisplay[] = [ + { + id: 'syncMetrics01', + dateQueued, + dateStarted, + status: SyncMetricsStatus.Running, + userRef: 'user01' + }, + { + id: 'syncMetrics02', + dateQueued, + dateStarted, + dateFinished, + status: SyncMetricsStatus.Failed, + userRef: 'user01', + errorDetails: 'An error occurred' + }, + { + id: 'syncMetrics03', + dateQueued, + dateStarted, + dateFinished, + status: SyncMetricsStatus.Successful, + userRef: 'user02' + } + ]; + this.setSyncMetrics(syncMetrics, args.unpagedCount ?? syncMetrics.length); + } + + setSyncMetrics(syncMetrics: SyncMetricsDisplay[], unpagedCount: number = syncMetrics.length): void { + when(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).thenResolve({ + results: syncMetrics, + unpagedCount + } as QueryResults); + } + + setBrowserOnlineStatus(status: boolean): void { + this.testOnlineStatusService.setIsOnline(status); + } + + wait(): void { + this.fixture.detectChanges(); + tick(); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.ts new file mode 100644 index 00000000000..f42102f5edd --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-log.component.ts @@ -0,0 +1,161 @@ +import { Component, DestroyRef, OnInit } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { TranslocoModule } from '@ngneat/transloco'; +import { + BehaviorSubject, + catchError, + combineLatest, + distinctUntilChanged, + EMPTY, + finalize, + from, + map, + Observable, + of, + switchMap +} from 'rxjs'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { CopyComponent } from 'xforge-common/copy/copy.component'; +import { DataLoadingComponent } from 'xforge-common/data-loading-component'; +import { NoticeService } from 'xforge-common/notice.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { OwnerComponent } from 'xforge-common/owner/owner.component'; +import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; +import { PermissionsService } from '../../core/permissions.service'; +import { SFProjectService } from '../../core/sf-project.service'; +import { SyncMetricsDisplay, SyncMetricsStatus } from './sync-metrics-display'; + +const statusIcons: Record = { + [SyncMetricsStatus.Queued]: 'schedule', + [SyncMetricsStatus.Running]: 'sync', + [SyncMetricsStatus.Successful]: 'check_circle', + [SyncMetricsStatus.Cancelled]: 'cancel', + [SyncMetricsStatus.Failed]: 'error' +}; + +/** A sync history entry prepared for display. */ +interface Row { + syncMetrics: SyncMetricsDisplay; + statusIcon: string; + statusKey: string; + /** The raw ISO timestamp of the sync. OwnerComponent localizes it for display. */ + dateTime: string; + userRef?: string; +} + +/** + * Shows the sync history of the project on the sync page. Serval and system administrators can additionally expand + * the error details of failed syncs. + */ +@Component({ + selector: 'app-sync-log', + templateUrl: './sync-log.component.html', + styleUrls: ['./sync-log.component.scss'], + imports: [TranslocoModule, MatButton, MatIcon, OwnerComponent, CopyComponent] +}) +export class SyncLogComponent extends DataLoadingComponent implements OnInit { + static readonly INITIAL_PAGE_SIZE = 5; + static readonly PAGE_INCREMENT = 10; + + rows: Row[] = []; + totalCount = 0; + + /** The ids of sync metrics whose error details are expanded. */ + private expandedErrorIds = new Set(); + + // The number of entries to show. Always fetching the first page with a growing page size (rather than appending + // subsequent pages) keeps the log correct when new syncs are queued between fetches. + private limit$ = new BehaviorSubject(SyncLogComponent.INITIAL_PAGE_SIZE); + private syncMetrics?: SyncMetricsDisplay[]; + + constructor( + noticeService: NoticeService, + private readonly activatedProjectService: ActivatedProjectService, + private readonly onlineStatusService: OnlineStatusService, + private readonly permissionsService: PermissionsService, + private readonly projectService: SFProjectService, + private destroyRef: DestroyRef + ) { + super(noticeService, 'SyncLogComponent'); + } + + get isOnline(): boolean { + return this.onlineStatusService.isOnline; + } + + get isLoading(): boolean { + return this.syncMetrics == null; + } + + /** Whether the user can see the error details of failed syncs. */ + get canSeeErrorDetails(): boolean { + return this.permissionsService.isServalAdmin || this.permissionsService.isSystemAdmin; + } + + get canShowMore(): boolean { + return this.rows.length < this.totalCount; + } + + ngOnInit(): void { + // Refetch the log when the number of queued syncs changes, so that newly queued and newly finished syncs appear + // without the user reloading the page + const queuedCount$: Observable = this.activatedProjectService.changes$.pipe( + map(projectDoc => projectDoc?.data?.sync?.queuedCount ?? 0), + distinctUntilChanged() + ); + combineLatest([ + this.activatedProjectService.projectId$.pipe(filterNullish()), + this.limit$, + queuedCount$, + this.onlineStatusService.onlineStatus$ + ]) + .pipe( + switchMap(([projectId, limit, _queuedCount, isOnline]) => { + if (!isOnline) return EMPTY; + this.loadingStarted(); + // A failed fetch keeps the current rows; the next queued sync or show more click will retry. Responses + // superseded by a newer fetch are discarded by switchMap before reaching subscribe. + return from(this.projectService.onlineSyncMetrics(projectId, 0, limit)).pipe( + catchError(() => of(undefined)), + finalize(() => this.loadingFinished()) + ); + }), + filterNullish(), + quietTakeUntilDestroyed(this.destroyRef) + ) + .subscribe(queryResults => { + this.totalCount = queryResults.unpagedCount ?? 0; + this.syncMetrics = Array.isArray(queryResults.results) ? queryResults.results : []; + this.generateRows(); + }); + } + + showMore(): void { + this.limit$.next(this.limit$.value + SyncLogComponent.PAGE_INCREMENT); + } + + isErrorExpanded(syncMetricsId: string): boolean { + return this.expandedErrorIds.has(syncMetricsId); + } + + toggleErrorDetails(syncMetricsId: string): void { + if (!this.expandedErrorIds.delete(syncMetricsId)) { + this.expandedErrorIds.add(syncMetricsId); + } + } + + private generateRows(): void { + const rows: Row[] = []; + for (const syncMetrics of this.syncMetrics ?? []) { + rows.push({ + syncMetrics, + statusIcon: statusIcons[syncMetrics.status] ?? 'help', + statusKey: `status_${syncMetrics.status.toLowerCase()}`, + dateTime: syncMetrics.dateStarted ?? syncMetrics.dateQueued, + userRef: syncMetrics.userRef + }); + } + this.rows = rows; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-metrics-display.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-metrics-display.ts new file mode 100644 index 00000000000..696e53a57b3 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync-log/sync-metrics-display.ts @@ -0,0 +1,19 @@ +/** One entry of a project's sync history, as returned by the syncMetrics RPC method. */ +export interface SyncMetricsDisplay { + id: string; + dateQueued: string; + dateStarted?: string; + dateFinished?: string; + status: SyncMetricsStatus; + userRef?: string; + /** The details of the error that caused the sync to fail. Only provided to serval and system administrators. */ + errorDetails?: string; +} + +export enum SyncMetricsStatus { + Queued = 'Queued', + Running = 'Running', + Successful = 'Successful', + Cancelled = 'Cancelled', + Failed = 'Failed' +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.html index b1590916a03..f2dfed89180 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.html @@ -3,6 +3,8 @@

{{ t("synchronize_project", { projectName: projectName }) }}

@if (!isAppOnline) { {{ t("connect_network_to_synchronize") }} + } @else if (isServalAdminReadOnly) { + } @if (syncDisabled) { @@ -17,17 +19,17 @@

{{ t("synchronize_project", { projectName: projectName }
- @if (showParatextLogin && isAppOnline) { + @if (showParatextLogin && isAppOnline && !isServalAdminReadOnly) { } - @if (isLoggedIntoParatext || isLoadingData || !isAppOnline) { + @if (isLoggedIntoParatext || isLoadingData || !isAppOnline || isServalAdminReadOnly) { @if (!syncActive) {
+

diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.spec.ts index bc8844023bd..9d17974bc5d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.spec.ts @@ -3,10 +3,13 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testin import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; import { CookieService } from 'ngx-cookie-service'; +import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; import { SFProject } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; +import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { createTestProject } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { of } from 'rxjs'; import { anyString, anything, mock, verify, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { AuthService } from 'xforge-common/auth.service'; import { BugsnagService } from 'xforge-common/bugsnag.service'; import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; @@ -18,6 +21,7 @@ import { TestOnlineStatusService } from 'xforge-common/test-online-status.servic import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; +import { UserService } from 'xforge-common/user.service'; import { SFProjectDoc } from '../core/models/sf-project-doc'; import { SF_TYPE_REGISTRY } from '../core/models/sf-type-registry'; import { ParatextService } from '../core/paratext.service'; @@ -37,6 +41,8 @@ const mockedProjectService = mock(SFProjectService); const mockedProjectNotificationService = mock(ProjectNotificationService); const mockedBugsnagService = mock(BugsnagService); const mockedCookieService = mock(CookieService); +const mockedUserService = mock(UserService); +const mockedActivatedProjectService = mock(ActivatedProjectService); describe('SyncComponent', () => { configureTestingModule(() => ({ @@ -54,6 +60,8 @@ describe('SyncComponent', () => { { provide: SFProjectService, useMock: mockedProjectService }, { provide: BugsnagService, useMock: mockedBugsnagService }, { provide: CookieService, useMock: mockedCookieService }, + { provide: UserService, useMock: mockedUserService }, + { provide: ActivatedProjectService, useMock: mockedActivatedProjectService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService } ] })); @@ -89,6 +97,44 @@ describe('SyncComponent', () => { expect(env.offlineMessage).toBeNull(); })); + it('is read-only for a serval admin who is not on the project', fakeAsync(() => { + const env = new TestEnvironment({ isServalAdmin: true, isUserOnProject: false, isParatextAccountConnected: false }); + + expect(env.servalAdminReadOnlyNotice).not.toBeNull(); + expect(env.offlineMessage).toBeNull(); + // The card is shown even though the serval admin is not logged in to Paratext, but the button is disabled + expect(env.paratextAccountNotice).toBeNull(); + expect(env.syncButton.nativeElement.disabled).toBe(true); + expect(env.lastSyncDate.textContent).toContain('Last synced on'); + + env.clickElement(env.syncButton); + verify(mockedProjectService.onlineSync(anything())).never(); + })); + + it('allows a serval admin to sync a resource they have read access to', fakeAsync(() => { + const env = new TestEnvironment({ + isServalAdmin: true, + isResourceProject: true, + userRole: SFProjectRole.ParatextObserver + }); + + expect(env.servalAdminReadOnlyNotice).toBeNull(); + expect(env.syncButton.nativeElement.disabled).toBe(false); + + env.clickElement(env.syncButton); + verify(mockedProjectService.onlineSync(env.projectId)).once(); + })); + + it('is editable for a serval admin who is also a project admin', fakeAsync(() => { + const env = new TestEnvironment({ isServalAdmin: true }); + + expect(env.servalAdminReadOnlyNotice).toBeNull(); + expect(env.syncButton.nativeElement.disabled).toBe(false); + + env.clickElement(env.syncButton); + verify(mockedProjectService.onlineSync(env.projectId)).once(); + })); + it('should sync project when the button is clicked', fakeAsync(() => { const env = new TestEnvironment(); const previousLastSyncDate = env.component.lastSyncDate; @@ -249,6 +295,10 @@ interface SyncComponentTestConstructorArgs { isSyncDisabled?: boolean; lastSyncWasSuccessful?: boolean; lastSyncErrorCode?: number; + isServalAdmin?: boolean; + isUserOnProject?: boolean; + isResourceProject?: boolean; + userRole?: SFProjectRole; } class TestEnvironment { @@ -269,8 +319,20 @@ class TestEnvironment { const isSyncDisabled: boolean = args.isSyncDisabled ?? false; const lastSyncWasSuccessful: boolean = args.lastSyncWasSuccessful ?? true; const lastSyncErrorCode: number = args.lastSyncErrorCode ?? 0; + const isServalAdmin: boolean = args.isServalAdmin ?? false; + const isUserOnProject: boolean = args.isUserOnProject ?? true; + const isResourceProject: boolean = args.isResourceProject ?? false; + const userRole: SFProjectRole = args.userRole ?? SFProjectRole.ParatextAdministrator; when(mockedActivatedRoute.params).thenReturn(of({ projectId: this.projectId })); + when(mockedActivatedProjectService.projectId$).thenReturn(of(this.projectId)); + when(mockedActivatedProjectService.changes$).thenReturn(of(undefined)); + when(mockedProjectService.onlineSyncMetrics(anything(), anything(), anything())).thenResolve({ + results: [], + unpagedCount: 0 + }); + when(mockedUserService.currentUserId).thenReturn('user01'); + when(mockedAuthService.currentUserRoles).thenReturn(isServalAdmin ? [SystemRole.ServalAdmin] : []); const ptUsername = isParatextAccountConnected ? 'Paratext User01' : ''; when(mockedParatextService.getParatextUsername()).thenReturn(of(ptUsername)); when(mockedProjectService.onlineSync(anything())).thenCall(id => { @@ -288,6 +350,9 @@ class TestEnvironment { id: this.projectId, data: createTestProject({ name: 'Sync Test Project', + // A 16 character paratext id identifies the project as a DBL resource + ...(isResourceProject ? { paratextId: 'resource90123456' } : {}), + userRoles: isUserOnProject ? { user01: userRole } : {}, sync: { queuedCount: isInProgress ? 1 : 0, lastSyncSuccessful: lastSyncWasSuccessful, @@ -353,6 +418,10 @@ class TestEnvironment { return this.fixture.nativeElement.querySelector('.offline-text'); } + get servalAdminReadOnlyNotice(): HTMLElement { + return this.fixture.nativeElement.querySelector('#serval-admin-read-only-notice'); + } + set onlineStatus(hasConnection: boolean) { this.testOnlineStatusService.setIsOnline(hasConnection); tick(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.ts index bc958fb6647..7557e0583d5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/sync/sync.component.ts @@ -20,9 +20,12 @@ import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { environment } from '../../environments/environment'; import { SFProjectDoc } from '../core/models/sf-project-doc'; import { ParatextService } from '../core/paratext.service'; +import { PermissionsService } from '../core/permissions.service'; import { SFProjectService } from '../core/sf-project.service'; import { NoticeComponent } from '../shared/notice/notice.component'; import { ParatextAccountNoticeComponent } from '../shared/paratext-account-notice/paratext-account-notice.component'; +import { ServalAdminReadOnlyNoticeComponent } from '../shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component'; +import { SyncLogComponent } from './sync-log/sync-log.component'; import { SyncProgressComponent } from './sync-progress/sync-progress.component'; /** Reports as to whether a given project is actively syncing right now. */ export function isSFProjectSyncing(project: SFProjectProfile): boolean { @@ -41,9 +44,11 @@ enum SyncErrorCodes { TranslocoModule, NoticeComponent, ParatextAccountNoticeComponent, + ServalAdminReadOnlyNoticeComponent, MatCard, MatButton, MatIcon, + SyncLogComponent, SyncProgressComponent, MatHint, MatTooltip @@ -69,11 +74,22 @@ export class SyncComponent extends DataLoadingComponent implements OnInit { private readonly onlineStatusService: OnlineStatusService, private readonly dialogService: DialogService, private readonly authService: AuthService, + private readonly permissionsService: PermissionsService, private destroyRef: DestroyRef ) { super(noticeService, 'SyncComponent'); } + /** Whether the user can initiate a synchronization of the project. */ + get canInitiateSync(): boolean { + return this.projectDoc != null && this.permissionsService.canInitiateSync(this.projectDoc); + } + + /** Whether the page is read-only because the user is only here by virtue of being a serval admin. */ + get isServalAdminReadOnly(): boolean { + return !this.canInitiateSync && this.permissionsService.isServalAdmin; + } + get isLoggedIntoParatext(): boolean { return this.paratextUsername != null && this.paratextUsername.length > 0; } @@ -200,7 +216,7 @@ export class SyncComponent extends DataLoadingComponent implements OnInit { } syncProject(): void { - if (this.projectDoc == null) { + if (this.projectDoc == null || !this.canInitiateSync) { return; } this._syncActive = true; @@ -215,7 +231,7 @@ export class SyncComponent extends DataLoadingComponent implements OnInit { } cancelSync(): void { - if (this.projectDoc == null) { + if (this.projectDoc == null || !this.canInitiateSync) { return; } void this.projectService.onlineCancelSync(this.projectDoc.id); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-list.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-list.component.spec.ts index 6c444d51ab6..9670c2b3bfa 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-list.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-list.component.spec.ts @@ -5,6 +5,7 @@ import { provideRouter } from '@angular/router'; import { BehaviorSubject, of } from 'rxjs'; import { anything, mock, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; import { I18nService } from 'xforge-common/i18n.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; @@ -21,6 +22,7 @@ import { DraftGenerationService } from '../draft-generation.service'; import { DraftHistoryListComponent } from './draft-history-list.component'; const mockedActivatedProjectService = mock(ActivatedProjectService); +const mockedAuthService = mock(AuthService); const mockedDraftGenerationService = mock(DraftGenerationService); const mockedI18nService = mock(I18nService); const mockedProjectNotificationService = mock(ProjectNotificationService); @@ -37,6 +39,7 @@ describe('DraftHistoryListComponent', () => { provideHttpClientTesting(), provideTestOnlineStatus(), { provide: ActivatedProjectService, useMock: mockedActivatedProjectService }, + { provide: AuthService, useMock: mockedAuthService }, { provide: DraftGenerationService, useMock: mockedDraftGenerationService }, { provide: I18nService, useMock: mockedI18nService }, { provide: ProjectNotificationService, useMock: mockedProjectNotificationService }, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts index 85e6ffd7526..cccdd047382 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts @@ -4,6 +4,7 @@ import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf- import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { Subject } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { AuthService } from 'xforge-common/auth.service'; import { UserService } from 'xforge-common/user.service'; import { SFProjectDoc } from '../../../../core/models/sf-project-doc'; import { PermissionsService } from '../../../../core/permissions.service'; @@ -445,6 +446,7 @@ class TestEnvironment { // Use a real PermissionsService so the role/resource permission logic is exercised, not stubbed. const permissionsService = new PermissionsService( + instance(mock(AuthService)), instance(this.mockedUserService), instance(this.mockedProjectService) ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.html index 043cc100ff8..86006119757 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.html @@ -2,6 +2,8 @@
@if (!isAppOnline) {

{{ t("connect_network_to_manage_users") }}

+ } @else if (isServalAdminReadOnly) { + }
@@ -88,7 +90,7 @@

- @if (!userRow.paratextMemberNotConnected) { + @if (!userRow.paratextMemberNotConnected && hasEditRights) { @@ -135,6 +137,8 @@

}

} - + @if (hasEditRights) { + + }
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.spec.ts index 8469c7341cf..0b3d78baee4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.spec.ts @@ -11,9 +11,11 @@ import { ParatextUserProfile } from 'realtime-server/lib/esm/scriptureforge/mode import { SFProject, SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { createTestProject } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; +import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; import { of } from 'rxjs'; import { anything, mock, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; import { AvatarComponent } from 'xforge-common/avatar/avatar.component'; import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; import { DialogService } from 'xforge-common/dialog.service'; @@ -37,6 +39,7 @@ import { provideQuillRegistrations } from '../../shared/text/quill-editor-regist import { CollaboratorsComponent, UserType } from './collaborators.component'; const mockedActivatedProject = mock(ActivatedProjectService); +const mockedAuthService = mock(AuthService); const mockedNoticeService = mock(NoticeService); const mockedProjectService = mock(SFProjectService); const mockedUserService = mock(UserService); @@ -50,6 +53,7 @@ describe('CollaboratorsComponent', () => { provideTestRealtime(SF_TYPE_REGISTRY), provideTestOnlineStatus(), { provide: ActivatedProjectService, useMock: mockedActivatedProject }, + { provide: AuthService, useMock: mockedAuthService }, { provide: NoticeService, useMock: mockedNoticeService }, { provide: SFProjectService, useMock: mockedProjectService }, { provide: UserService, useMock: mockedUserService }, @@ -117,6 +121,24 @@ describe('CollaboratorsComponent', () => { env.cleanup(); })); + it('is read-only for a serval admin who is not on the project', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); + when(mockedUserService.currentUserId).thenReturn('serval01'); + env.setupProjectData(); + env.fixture.detectChanges(); + tick(); + env.fixture.detectChanges(); + + expect(env.servalAdminReadOnlyNotice).not.toBeNull(); + expect(env.offlineMessage).toBeNull(); + // Users are still listed, but without any management controls + expect(env.userRowsByCategory(UserType.Paratext).length).toBeGreaterThan(0); + expect(env.userRowMoreMenuElement(0, UserType.Paratext)).toBeNull(); + expect(env.shareControl).toBeNull(); + verify(mockedProjectService.onlineInvitedUsers(anything())).never(); + })); + it('display paratext users not on project', fakeAsync(() => { const env = new TestEnvironment(); env.setupProjectData(); @@ -204,16 +226,10 @@ describe('CollaboratorsComponent', () => { ]); })); - it('handle error from invited users query, when user is not on project', fakeAsync(() => { - // If an admin user is removed from the project, or loses admin - // privileges, while looking at the component, they will run loadUsers - // and throw an error calling onlineInvitedUsers. - // Handle that error. - + it('does not query invited users when user is not on project', fakeAsync(() => { + // If an admin user is removed from the project, or loses admin privileges, while looking at the component, they + // will run loadUsers, which must not call onlineInvitedUsers (the server would reject it). const env = new TestEnvironment(); - when(mockedProjectService.onlineInvitedUsers(env.project01Id)).thenThrow( - new CommandError(CommandErrorCode.Other, 'error', null) - ); env.setupProjectData({ // No user01 user02: SFProjectRole.ParatextTranslator, @@ -223,15 +239,13 @@ describe('CollaboratorsComponent', () => { expect(() => { tick(); }).not.toThrow(); - verify(mockedNoticeService.show(anything())).once(); + verify(mockedProjectService.onlineInvitedUsers(anything())).never(); + verify(mockedNoticeService.show(anything())).never(); tick(); })); - it('handle error from invited users query, when user is not an admin', fakeAsync(() => { + it('does not query invited users when user is not an admin', fakeAsync(() => { const env = new TestEnvironment(); - when(mockedProjectService.onlineInvitedUsers(env.project01Id)).thenThrow( - new CommandError(CommandErrorCode.Other, 'error', null) - ); env.setupProjectData({ // user01 is not an admin user01: SFProjectRole.CommunityChecker, @@ -242,6 +256,21 @@ describe('CollaboratorsComponent', () => { expect(() => { tick(); }).not.toThrow(); + verify(mockedProjectService.onlineInvitedUsers(anything())).never(); + verify(mockedNoticeService.show(anything())).never(); + tick(); + })); + + it('handles error from invited users query', fakeAsync(() => { + const env = new TestEnvironment(); + when(mockedProjectService.onlineInvitedUsers(env.project01Id)).thenThrow( + new CommandError(CommandErrorCode.Other, 'error', null) + ); + env.setupProjectData(); + env.fixture.detectChanges(); + expect(() => { + tick(); + }).not.toThrow(); verify(mockedNoticeService.show(anything())).once(); tick(); })); @@ -420,6 +449,7 @@ class TestEnvironment { this.realtimeService.subscribe(UserProfileDoc.COLLECTION, userId) ); when(mockedUserService.currentUserId).thenReturn('user01'); + when(mockedAuthService.currentUserRoles).thenReturn([]); when(mockedProjectService.get(anything())).thenCall(projectId => this.realtimeService.subscribe(SFProjectDoc.COLLECTION, projectId) ); @@ -462,6 +492,14 @@ class TestEnvironment { return this.fixture.debugElement.query(By.css('#collaborators-offline-message')); } + get servalAdminReadOnlyNotice(): DebugElement { + return this.fixture.debugElement.query(By.css('#serval-admin-read-only-notice')); + } + + get shareControl(): DebugElement { + return this.fixture.debugElement.query(By.css('app-share-control')); + } + set onlineStatus(hasConnection: boolean) { this.testOnlineStatusService.setIsOnline(hasConnection); tick(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.ts index ce40f4b6aff..18023b41b3b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/users/collaborators/collaborators.component.ts @@ -25,8 +25,10 @@ import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { XFValidators } from 'xforge-common/xfvalidators'; import { InviteeStatus } from '../../core/models/invitee-status'; import { SFProjectDoc } from '../../core/models/sf-project-doc'; +import { PermissionsService } from '../../core/permissions.service'; import { SFProjectService } from '../../core/sf-project.service'; import { NoticeComponent } from '../../shared/notice/notice.component'; +import { ServalAdminReadOnlyNoticeComponent } from '../../shared/serval-admin-read-only-notice/serval-admin-read-only-notice.component'; import { ShareControlComponent } from '../../shared/share/share-control.component'; import { RolesAndPermissionsDialogComponent } from '../roles-and-permissions/roles-and-permissions-dialog.component'; @@ -80,6 +82,7 @@ export enum UserType { MatRowDef, MatRow, MatHint, + ServalAdminReadOnlyNoticeComponent, ShareControlComponent ] }) @@ -102,11 +105,22 @@ export class CollaboratorsComponent extends DataLoadingComponent implements OnIn private readonly onlineStatusService: OnlineStatusService, private readonly dialogService: DialogService, readonly urls: ExternalUrlService, + private readonly permissionsService: PermissionsService, private destroyRef: DestroyRef ) { super(noticeService, 'CollaboratorsComponent'); } + /** Whether the user has the project role required to manage project users. */ + get hasEditRights(): boolean { + return this.projectDoc != null && this.permissionsService.canEditProjectSettings(this.projectDoc); + } + + /** Whether the page is read-only because the user is only here by virtue of being a serval admin. */ + get isServalAdminReadOnly(): boolean { + return !this.hasEditRights && this.permissionsService.isServalAdmin; + } + get isLoading(): boolean { return this._userRows == null; } @@ -247,20 +261,23 @@ export class CollaboratorsComponent extends DataLoadingComponent implements OnIn ) ); - try { - inviteeRows.push( - ...(await this.projectService.onlineInvitedUsers(this.projectId)).map( - invitee => - ({ - id: '', - user: { email: invitee.email }, - role: invitee.role, - inviteeStatus: invitee - }) as Row - ) - ); - } catch { - this.noticeService.show(this.i18n.translateStatic('collaborators.problem_loading_invited_users')); + // Only project admins may list invited users; the server rejects the request for anyone else. + if (this.hasEditRights) { + try { + inviteeRows.push( + ...(await this.projectService.onlineInvitedUsers(this.projectId)).map( + invitee => + ({ + id: '', + user: { email: invitee.email }, + role: invitee.role, + inviteeStatus: invitee + }) as Row + ) + ); + } catch { + this.noticeService.show(this.i18n.translateStatic('collaborators.problem_loading_invited_users')); + } } this._userRows = this.sortUsers(userRows, otherParatextMemberRows, inviteeRows); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json index 32c0dc9e0c2..016e51b0526 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json +++ b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json @@ -951,6 +951,19 @@ "sync_user_permissions_failure_message": "The last user to synchronize this project no longer has permission to sync. Please contact the project administrator.", "user_permissions_failure_dialog_message": "You no longer have permission to synchronize this project with Paratext. Please contact the project administrator." }, + "sync_log": { + "initiated_by": "Initiated by", + "no_syncs": "This project has not been synchronized yet.", + "offline": "Connect to the internet to see the sync history.", + "show_more": "Show more", + "status": "Status", + "status_cancelled": "Cancelled", + "status_failed": "Failed", + "status_queued": "Queued", + "status_running": "Running", + "status_successful": "Successful", + "sync_history": "Sync history" + }, "tab_group_header": { "no_tabs_available": "No tabs available." }, diff --git a/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs b/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs index 899c266bcbf..4135ac9b4ab 100644 --- a/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs +++ b/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs @@ -770,6 +770,39 @@ await projectService.GetEventMetricsAsync( } } + public async Task SyncMetrics(string projectId, int pageIndex, int pageSize) + { + try + { + return Ok(await projectService.GetSyncMetricsAsync(UserId, SystemRoles, projectId, pageIndex, pageSize)); + } + catch (ForbiddenException) + { + return ForbiddenError(); + } + catch (DataNotFoundException dnfe) + { + return NotFoundError(dnfe.Message); + } + catch (FormatException fe) + { + return InvalidParamsError(fe.Message); + } + catch (Exception) + { + _exceptionHandler.RecordEndpointInfoForException( + new Dictionary + { + { "method", "SyncMetrics" }, + { "projectId", projectId }, + { "pageIndex", pageIndex.ToString() }, + { "pageSize", pageSize.ToString() }, + } + ); + throw; + } + } + public IRpcMethodResult RetrievePreTranslationStatus(string projectId) { try diff --git a/src/SIL.XForge.Scripture/DataAccess/SFDataAccessServiceCollectionExtensions.cs b/src/SIL.XForge.Scripture/DataAccess/SFDataAccessServiceCollectionExtensions.cs index f5d2f0c4bd2..d87e21ecd36 100644 --- a/src/SIL.XForge.Scripture/DataAccess/SFDataAccessServiceCollectionExtensions.cs +++ b/src/SIL.XForge.Scripture/DataAccess/SFDataAccessServiceCollectionExtensions.cs @@ -35,7 +35,9 @@ public static IServiceCollection AddSFDataAccess(this IServiceCollection service cm => cm.MapIdProperty(sm => sm.Id), im => im.CreateOne( - new CreateIndexModel(Builders.IndexKeys.Ascending(sm => sm.ProjectRef)) + new CreateIndexModel( + Builders.IndexKeys.Ascending(sm => sm.ProjectRef).Descending(sm => sm.DateQueued) + ) ) ); services.AddMongoRepository( diff --git a/src/SIL.XForge.Scripture/Models/SyncMetricsDisplay.cs b/src/SIL.XForge.Scripture/Models/SyncMetricsDisplay.cs new file mode 100644 index 00000000000..2725ae18255 --- /dev/null +++ b/src/SIL.XForge.Scripture/Models/SyncMetricsDisplay.cs @@ -0,0 +1,37 @@ +using System; +using System.Text.Json.Serialization; + +namespace SIL.XForge.Scripture.Models; + +/// +/// The subset of that is displayed in the sync log on the sync page. +/// +/// +/// The log entries and statistics are deliberately excluded, as they can be very large. +/// +public class SyncMetricsDisplay +{ + public required string Id { get; init; } + + public DateTime DateQueued { get; set; } + + public DateTime? DateStarted { get; set; } + + public DateTime? DateFinished { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public SyncStatus Status { get; set; } + + /// + /// Gets or sets the id of the user who initiated the sync. + /// + public string? UserRef { get; set; } + + /// + /// Gets or sets the details of the error that caused the sync to fail. + /// + /// + /// This is only populated for serval administrators and system administrators. + /// + public string? ErrorDetails { get; set; } +} diff --git a/src/SIL.XForge.Scripture/Services/ISFProjectService.cs b/src/SIL.XForge.Scripture/Services/ISFProjectService.cs index 65dd37462ab..b040c6a9213 100644 --- a/src/SIL.XForge.Scripture/Services/ISFProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/ISFProjectService.cs @@ -51,6 +51,13 @@ Task> GetEventMetricsAsync( int pageIndex = 0, int pageSize = int.MaxValue ); + Task> GetSyncMetricsAsync( + string curUserId, + string[] systemRoles, + string projectId, + int pageIndex, + int pageSize + ); Task GetProjectAsync(string projectId); SFProjectSecret GetProjectSecretByShareKey(string shareKey); Task ReserveLinkSharingKeyAsync(string curUserId, string shareKey, int daysBeforeExpiration); diff --git a/src/SIL.XForge.Scripture/Services/SFProjectService.cs b/src/SIL.XForge.Scripture/Services/SFProjectService.cs index 68ddad7f7ba..0ad58cad39b 100644 --- a/src/SIL.XForge.Scripture/Services/SFProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/SFProjectService.cs @@ -44,6 +44,7 @@ public class SFProjectService : ProjectService, ISFP private readonly IRepository _userSecrets; private readonly IRepository _projectSecrets; private readonly IRepository _translateMetrics; + private readonly IRepository _syncMetrics; private readonly IEmailService _emailService; private readonly ISecurityService _securityService; private readonly IStringLocalizer _localizer; @@ -68,6 +69,7 @@ public SFProjectService( IParatextService paratextService, IRepository userSecrets, IRepository translateMetrics, + IRepository syncMetrics, IStringLocalizer localizer, ITransceleratorService transceleratorService, IBackgroundJobClient backgroundJobClient, @@ -85,6 +87,7 @@ IMongoClient mongoClient _userSecrets = userSecrets; _projectSecrets = projectSecrets; _translateMetrics = translateMetrics; + _syncMetrics = syncMetrics; _emailService = emailService; _securityService = securityService; _localizer = localizer; @@ -1002,17 +1005,7 @@ public async Task> GetEventMetricsAsync( int pageSize = int.MaxValue ) { - // Ensure that the page index is valid - if (pageIndex < 0) - { - throw new FormatException($"{nameof(pageIndex)} is not a valid page index."); - } - - // Ensure that the page size is valid - if (pageSize <= 0) - { - throw new FormatException($"{nameof(pageSize)} is not a valid page size."); - } + ValidatePagingParameters(pageIndex, pageSize); if (projectId is not null) { @@ -1059,6 +1052,80 @@ public async Task> GetEventMetricsAsync( ); } + /// + /// Gets the sync history for a project, most recently queued first. + /// + /// The current user identifier. + /// The current user's system roles. + /// The project identifier. + /// The page index. + /// The page size. + /// The sync metrics for display in the sync log. + /// The project does not exist. + /// The user does not have permission to view the sync log. + /// The page index or page size is invalid. + /// Error details are only included for serval administrators and system administrators. + public async Task> GetSyncMetricsAsync( + string curUserId, + string[] systemRoles, + string projectId, + int pageIndex, + int pageSize + ) + { + ValidatePagingParameters(pageIndex, pageSize); + + SFProject project = await GetProjectAsync(projectId); + + // Anyone who can view the sync page can view the sync log, but only serval administrators + // and system administrators can see the error details + bool isSiteAdmin = systemRoles.Contains(SystemRole.SystemAdmin) || systemRoles.Contains(SystemRole.ServalAdmin); + if (!isSiteAdmin && !_projectRights.HasRight(project, curUserId, SFProjectDomain.Texts, Operation.Edit)) + { + throw new ForbiddenException(); + } + + bool canSeeErrorDetails = isSiteAdmin; + + IQueryable query = _syncMetrics.Query().Where(m => m.ProjectRef == projectId); + var countTask = query.CountAsync(); + + // Project in the database query so that the log entries are never retrieved + var resultsTask = query + .OrderByDescending(m => m.DateQueued) + .Skip(pageIndex * pageSize) + .Take(pageSize) + .Select(m => new SyncMetricsDisplay + { + Id = m.Id, + DateQueued = m.DateQueued, + DateStarted = m.DateStarted, + DateFinished = m.DateFinished, + Status = m.Status, + UserRef = m.UserRef, + ErrorDetails = canSeeErrorDetails ? m.ErrorDetails : null, + }) + .ToListAsync(); + + await Task.WhenAll(countTask, resultsTask); + + return new QueryResults { Results = resultsTask.Result, UnpagedCount = countTask.Result }; + } + + /// The page index or page size is invalid. + private static void ValidatePagingParameters(int pageIndex, int pageSize) + { + if (pageIndex < 0) + { + throw new FormatException($"{nameof(pageIndex)} is not a valid page index."); + } + + if (pageSize <= 0) + { + throw new FormatException($"{nameof(pageSize)} is not a valid page size."); + } + } + public SFProjectSecret GetProjectSecretByShareKey(string shareKey) { SFProjectSecret projectSecret = diff --git a/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs b/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs index 0a4b2c3bd6f..65170176536 100644 --- a/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs @@ -318,6 +318,81 @@ public void EventMetrics_UnknownError() env.ExceptionHandler.Received().RecordEndpointInfoForException(Arg.Any>()); } + [Test] + public async Task SyncMetrics_Success() + { + var env = new TestEnvironment(); + const int pageIndex = 0; + const int pageSize = 10; + + // SUT + var result = await env.Controller.SyncMetrics(Project01, pageIndex, pageSize); + Assert.IsInstanceOf(result); + await env.SFProjectService.Received().GetSyncMetricsAsync(User01, Roles, Project01, pageIndex, pageSize); + } + + [Test] + public async Task SyncMetrics_Forbidden() + { + var env = new TestEnvironment(); + const int pageIndex = 0; + const int pageSize = 10; + env.SFProjectService.GetSyncMetricsAsync(User01, Roles, Project01, pageIndex, pageSize) + .Throws(new ForbiddenException()); + + // SUT + var result = await env.Controller.SyncMetrics(Project01, pageIndex, pageSize); + Assert.IsInstanceOf(result); + Assert.AreEqual(RpcControllerBase.ForbiddenErrorCode, (result as RpcMethodErrorResult)!.ErrorCode); + } + + [Test] + public async Task SyncMetrics_InvalidParams() + { + var env = new TestEnvironment(); + const int pageIndex = 0; + const int pageSize = 10; + const string errorMessage = "Invalid Format"; + env.SFProjectService.GetSyncMetricsAsync(User01, Roles, Project01, pageIndex, pageSize) + .Throws(new FormatException(errorMessage)); + + // SUT + var result = await env.Controller.SyncMetrics(Project01, pageIndex, pageSize); + Assert.IsInstanceOf(result); + Assert.AreEqual(errorMessage, (result as RpcMethodErrorResult)!.Message); + } + + [Test] + public async Task SyncMetrics_NotFound() + { + var env = new TestEnvironment(); + const int pageIndex = 0; + const int pageSize = 10; + const string errorMessage = "Not Found"; + env.SFProjectService.GetSyncMetricsAsync(User01, Roles, Project01, pageIndex, pageSize) + .Throws(new DataNotFoundException(errorMessage)); + + // SUT + var result = await env.Controller.SyncMetrics(Project01, pageIndex, pageSize); + Assert.IsInstanceOf(result); + Assert.AreEqual(errorMessage, (result as RpcMethodErrorResult)!.Message); + Assert.AreEqual(RpcControllerBase.NotFoundErrorCode, (result as RpcMethodErrorResult)!.ErrorCode); + } + + [Test] + public void SyncMetrics_UnknownError() + { + var env = new TestEnvironment(); + const int pageIndex = 0; + const int pageSize = 10; + env.SFProjectService.GetSyncMetricsAsync(User01, Roles, Project01, pageIndex, pageSize) + .Throws(new ArgumentNullException()); + + // SUT + Assert.ThrowsAsync(() => env.Controller.SyncMetrics(Project01, pageIndex, pageSize)); + env.ExceptionHandler.Received().RecordEndpointInfoForException(Arg.Any>()); + } + [Test] public async Task Invite_Success() { diff --git a/test/SIL.XForge.Scripture.Tests/Services/SFProjectServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/SFProjectServiceTests.cs index d856fc44449..49dd6ed5101 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/SFProjectServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/SFProjectServiceTests.cs @@ -4951,6 +4951,160 @@ public void GetEventMetrics_UserForbidden() ); } + [Test] + public void GetSyncMetrics_InvalidPageIndex() + { + var env = new TestEnvironment(); + + // SUT + Assert.ThrowsAsync(() => + env.Service.GetSyncMetricsAsync( + User01, + systemRoles: [SystemRole.User], + Project01, + pageIndex: -1, + pageSize: 10 + ) + ); + } + + [Test] + public void GetSyncMetrics_InvalidPageSize() + { + var env = new TestEnvironment(); + + // SUT + Assert.ThrowsAsync(() => + env.Service.GetSyncMetricsAsync( + User01, + systemRoles: [SystemRole.User], + Project01, + pageIndex: 0, + pageSize: 0 + ) + ); + } + + [Test] + public void GetSyncMetrics_InvalidProject() + { + var env = new TestEnvironment(); + + // SUT + Assert.ThrowsAsync(() => + env.Service.GetSyncMetricsAsync( + User01, + systemRoles: [SystemRole.User], + projectId: "invalid_project", + pageIndex: 0, + pageSize: 10 + ) + ); + } + + [Test] + public void GetSyncMetrics_UserForbidden() + { + var env = new TestEnvironment(); + + // SUT + Assert.ThrowsAsync(() => + env.Service.GetSyncMetricsAsync( + User05, + systemRoles: [SystemRole.User], + Project01, + pageIndex: 0, + pageSize: 10 + ) + ); + } + + [Test] + public async Task GetSyncMetrics_UserWithEditRightDoesNotSeeErrorDetails() + { + var env = new TestEnvironment(); + env.AddSyncMetrics(); + env.ProjectRights.HasRight( + Arg.Is(p => p.Id == Project01), + User02, + SFProjectDomain.Texts, + Operation.Edit + ) + .Returns(true); + + // SUT + QueryResults actual = await env.Service.GetSyncMetricsAsync( + User02, + systemRoles: [SystemRole.User], + Project01, + pageIndex: 0, + pageSize: 10 + ); + Assert.AreEqual(2, actual.UnpagedCount); + Assert.IsTrue(actual.Results.All(r => r.ErrorDetails == null)); + } + + [Test] + public async Task GetSyncMetrics_ServalAdminSeesErrorDetails() + { + var env = new TestEnvironment(); + env.AddSyncMetrics(); + + // SUT + QueryResults actual = await env.Service.GetSyncMetricsAsync( + User06, + systemRoles: [SystemRole.ServalAdmin], + Project01, + pageIndex: 0, + pageSize: 10 + ); + + // Sorted by date queued descending, and only sync metrics for this project are returned + Assert.AreEqual(2, actual.UnpagedCount); + SyncMetricsDisplay[] results = [.. actual.Results]; + Assert.AreEqual("syncMetrics02", results[0].Id); + Assert.AreEqual(SyncStatus.Failed, results[0].Status); + Assert.AreEqual("An error occurred", results[0].ErrorDetails); + Assert.AreEqual(User01, results[0].UserRef); + Assert.AreEqual("syncMetrics01", results[1].Id); + } + + [Test] + public async Task GetSyncMetrics_SystemAdminSeesErrorDetails() + { + var env = new TestEnvironment(); + env.AddSyncMetrics(); + + // SUT + QueryResults actual = await env.Service.GetSyncMetricsAsync( + User06, + systemRoles: [SystemRole.SystemAdmin], + Project01, + pageIndex: 0, + pageSize: 10 + ); + Assert.AreEqual("An error occurred", actual.Results.First().ErrorDetails); + } + + [Test] + public async Task GetSyncMetrics_Pagination() + { + var env = new TestEnvironment(); + env.AddSyncMetrics(); + + // SUT + QueryResults actual = await env.Service.GetSyncMetricsAsync( + User06, + systemRoles: [SystemRole.ServalAdmin], + Project01, + pageIndex: 1, + pageSize: 1 + ); + Assert.AreEqual(2, actual.UnpagedCount); + Assert.AreEqual(1, actual.Results.Count()); + Assert.AreEqual("syncMetrics01", actual.Results.First().Id); + } + [Test] public async Task SyncUserRoleAsync_DowngradesRole() { @@ -6026,6 +6180,7 @@ public TestEnvironment() UserIdsToParatextUsernames.Keys.Select(id => new UserSecret { Id = id }) ); var translateMetrics = new MemoryRepository(); + SyncMetrics = new MemoryRepository(); FileSystemService = Substitute.For(); var options = Options.Create(new LocalizationOptions { ResourcesPath = "Resources" }); var factory = new ResourceManagerStringLocalizerFactory(options, NullLoggerFactory.Instance); @@ -6164,6 +6319,7 @@ public TestEnvironment() ParatextService, UserSecrets, translateMetrics, + SyncMetrics, Localizer, TransceleratorService, BackgroundJobClient, @@ -6187,12 +6343,51 @@ public TestEnvironment() public IParatextService ParatextService { get; } public IStringLocalizer Localizer { get; } public MemoryRepository UserSecrets { get; } + public MemoryRepository SyncMetrics { get; } public ITransceleratorService TransceleratorService { get; set; } public IBackgroundJobClient BackgroundJobClient { get; } public ISFProjectRights ProjectRights { get; } public IGuidService GuidService { get; } public IMongoClient MongoClient { get; } + public void AddSyncMetrics() + { + SyncMetrics.Add( + new SyncMetrics + { + Id = "syncMetrics01", + ProjectRef = Project01, + UserRef = User01, + DateQueued = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + DateStarted = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc), + DateFinished = new DateTime(2024, 1, 1, 0, 2, 0, DateTimeKind.Utc), + Status = SyncStatus.Successful, + Log = ["log line"], + } + ); + SyncMetrics.Add( + new SyncMetrics + { + Id = "syncMetrics02", + ProjectRef = Project01, + UserRef = User01, + DateQueued = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc), + Status = SyncStatus.Failed, + ErrorDetails = "An error occurred", + } + ); + SyncMetrics.Add( + new SyncMetrics + { + Id = "syncMetrics03", + ProjectRef = Project02, + UserRef = User02, + DateQueued = new DateTime(2024, 3, 1, 0, 0, 0, DateTimeKind.Utc), + Status = SyncStatus.Successful, + } + ); + } + public SFProject GetProject(string id) => RealtimeService.GetRepository().Get(id); public bool ContainsProject(string id) => RealtimeService.GetRepository().Contains(id);