Skip to content

Commit c8a9690

Browse files
KasinhouMatus Kasak
andauthored
ZCU-DATA/Prevent an admin from deleting their own account (#1443)
* Prevent an admin from deleting their own account Backport of the UFAL self-delete guard from dtq-dev (dspace-angular #1335, #1357, #1373) to this customer branch. The EPeople registry and the EPerson form now hide/disable the delete action for the currently authenticated user (with an explanatory tooltip), show a contextual warning in the confirmation modal when the target is a submitter and/or an administrator, and surface a friendly notification when the backend rejects a self-delete. Shared logic lives in the new EPersonDeleteGuardService so both call sites stay in sync. Refs dataquest-dev/dspace-customers#855 * Reword the Czech self-delete / delete-warning messages Rewrites the four Czech strings for the self-delete guard so they read naturally rather than as literal translations, keeping the repository's established Czech terminology (uživatel / správce / záznamy / smazat) and active phrasing ("Jeho smazáním odeberete…" instead of the nominal "Smazání tohoto uživatele odebere…"). Wording is identical across all customer branches. Raised in review on PR #1447. Refs dataquest-dev/dspace-customers#855 --------- Co-authored-by: Matus Kasak <matus.kasak@dataquest.sk>
1 parent b2137e8 commit c8a9690

14 files changed

Lines changed: 824 additions & 65 deletions

src/app/access-control/access-control.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SharedModule } from '../shared/shared.module';
55
import { AccessControlRoutingModule } from './access-control-routing.module';
66
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
77
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
8+
import { EPersonDeleteGuardService } from './epeople-registry/eperson-delete-guard.service';
89
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
910
import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component';
1011
import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component';
@@ -57,6 +58,7 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
5758
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
5859
useValue: ValidateEmailErrorStateMatcher
5960
},
61+
EPersonDeleteGuardService,
6062
]
6163
})
6264
/**

src/app/access-control/epeople-registry/epeople-registry.component.html

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,26 @@ <h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | trans
7373
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
7474
<i class="fas fa-edit fa-fw"></i>
7575
</button>
76-
<button *ngIf="epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
77-
class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
78-
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
79-
<i class="fas fa-trash-alt fa-fw"></i>
80-
</button>
76+
<ng-container *ngIf="epersonDto.ableToDelete && currentAuthenticatedUserId">
77+
<ng-container *ngIf="isCurrentUser(epersonDto.eperson); else enabledDeleteButton">
78+
<span tabindex="0" [ngbTooltip]="selfDeleteWarningLabel | translate" container="body">
79+
<button [disabled]="true"
80+
tabindex="-1"
81+
[attr.aria-label]="selfDeleteWarningLabel | translate"
82+
class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
83+
type="button">
84+
<i class="fas fa-trash-alt fa-fw"></i>
85+
</button>
86+
</span>
87+
</ng-container>
88+
</ng-container>
89+
<ng-template #enabledDeleteButton>
90+
<button (click)="deleteEPerson(epersonDto.eperson)"
91+
class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
92+
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
93+
<i class="fas fa-trash-alt fa-fw"></i>
94+
</button>
95+
</ng-template>
8196
</div>
8297
</td>
8398
</tr>

src/app/access-control/epeople-registry/epeople-registry.component.spec.ts

Lines changed: 179 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,41 @@
11
import { Router } from '@angular/router';
2-
import { Observable, of as observableOf } from 'rxjs';
2+
import { defer, Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
33
import { CommonModule } from '@angular/common';
4-
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
4+
import { NO_ERRORS_SCHEMA } from '@angular/core';
55
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
66
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
77
import { BrowserModule, By } from '@angular/platform-browser';
88
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
99
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
1010
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model';
1111
import { RemoteData } from '../../core/data/remote-data';
12+
import { AuthService } from '../../core/auth/auth.service';
1213
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
1314
import { EPerson } from '../../core/eperson/models/eperson.model';
1415
import { PageInfo } from '../../core/shared/page-info.model';
16+
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
1517
import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
1618
import { NotificationsService } from '../../shared/notifications/notifications.service';
1719
import { EPeopleRegistryComponent } from './epeople-registry.component';
1820
import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock';
19-
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
21+
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
2022
import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock';
2123
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
2224
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
2325
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
2426
import { RouterStub } from '../../shared/testing/router.stub';
2527
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
28+
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
29+
import { EPersonDeleteGuardService } from './eperson-delete-guard.service';
2630
import { RequestService } from '../../core/data/request.service';
2731
import { PaginationService } from '../../core/pagination/pagination.service';
2832
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
33+
import { WorkspaceitemDataService } from '../../core/submission/workspaceitem-data.service';
34+
import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service';
2935
import { FindListOptions } from '../../core/data/find-list-options.model';
36+
import { SearchService } from '../../core/shared/search/search.service';
37+
import { DSpaceObject } from '../../core/shared/dspace-object.model';
38+
import { SearchObjects } from '../../shared/search/models/search-objects.model';
3039

3140
describe('EPeopleRegistryComponent', () => {
3241
let component: EPeopleRegistryComponent;
@@ -37,10 +46,35 @@ describe('EPeopleRegistryComponent', () => {
3746
let mockEPeople;
3847
let ePersonDataServiceStub: any;
3948
let authorizationService: AuthorizationDataService;
49+
let authService: jasmine.SpyObj<AuthService>;
50+
let workspaceItemDataService: jasmine.SpyObj<WorkspaceitemDataService>;
51+
let workflowItemDataService: jasmine.SpyObj<WorkflowItemDataService>;
52+
let searchService: jasmine.SpyObj<SearchService>;
53+
let notificationsService: NotificationsServiceStub;
4054
let modalService;
55+
let modalRef;
4156

4257
let paginationService;
4358

59+
const buildRemoteList = <T>(items: T[], totalElements = items.length) => createSuccessfulRemoteDataObject$(
60+
buildPaginatedList(new PageInfo({
61+
elementsPerPage: items.length || 1,
62+
totalElements,
63+
totalPages: 1,
64+
currentPage: 1
65+
}), items)
66+
);
67+
68+
const buildSearchObjects = (totalElements: number) => Object.assign(
69+
new SearchObjects<DSpaceObject>(),
70+
buildPaginatedList(new PageInfo({
71+
elementsPerPage: 1,
72+
totalElements,
73+
totalPages: 1,
74+
currentPage: 1
75+
}), [])
76+
);
77+
4478
beforeEach(waitForAsync(() => {
4579
jasmine.getEnv().allowRespy(true);
4680
mockEPeople = [EPersonMock, EPersonMock2];
@@ -118,8 +152,17 @@ describe('EPeopleRegistryComponent', () => {
118152
authorizationService = jasmine.createSpyObj('authorizationService', {
119153
isAuthorized: observableOf(true)
120154
});
155+
authService = jasmine.createSpyObj('authService', ['getAuthenticatedUserFromStore']);
156+
authService.getAuthenticatedUserFromStore.and.returnValue(observableOf(EPersonMock2));
157+
workspaceItemDataService = jasmine.createSpyObj('workspaceItemDataService', ['searchBy']);
158+
workspaceItemDataService.searchBy.and.returnValue(buildRemoteList([], 0));
159+
workflowItemDataService = jasmine.createSpyObj('workflowItemDataService', ['searchBy']);
160+
workflowItemDataService.searchBy.and.returnValue(buildRemoteList([], 0));
161+
searchService = jasmine.createSpyObj('searchService', ['search']);
162+
searchService.search.and.returnValue(createSuccessfulRemoteDataObject$(buildSearchObjects(0)));
121163
builderService = getMockFormBuilderService();
122164
translateService = getMockTranslateService();
165+
notificationsService = new NotificationsServiceStub();
123166

124167
paginationService = new PaginationServiceStub();
125168
TestBed.configureTestingModule({
@@ -134,12 +177,20 @@ describe('EPeopleRegistryComponent', () => {
134177
declarations: [EPeopleRegistryComponent],
135178
providers: [
136179
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
137-
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
180+
{ provide: NotificationsService, useValue: notificationsService },
138181
{ provide: AuthorizationDataService, useValue: authorizationService },
182+
{ provide: AuthService, useValue: authService },
139183
{ provide: FormBuilderService, useValue: builderService },
184+
{ provide: WorkspaceitemDataService, useValue: workspaceItemDataService },
185+
{ provide: WorkflowItemDataService, useValue: workflowItemDataService },
186+
{ provide: SearchService, useValue: searchService },
187+
EPersonDeleteGuardService,
140188
{ provide: Router, useValue: new RouterStub() },
141189
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) },
142-
{ provide: PaginationService, useValue: paginationService }
190+
{ provide: PaginationService, useValue: paginationService },
191+
{ provide: DSONameService, useValue: jasmine.createSpyObj('dsoNameService', {
192+
getName: (dso: any) => dso?.name ?? dso?.email ?? dso?.id,
193+
}) },
143194
],
144195
schemas: [NO_ERRORS_SCHEMA]
145196
}).compileComponents();
@@ -149,7 +200,8 @@ describe('EPeopleRegistryComponent', () => {
149200
fixture = TestBed.createComponent(EPeopleRegistryComponent);
150201
component = fixture.componentInstance;
151202
modalService = (component as any).modalService;
152-
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
203+
modalRef = Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) });
204+
spyOn(modalService, 'open').and.returnValue(modalRef);
153205
fixture.detectChanges();
154206
});
155207

@@ -226,6 +278,125 @@ describe('EPeopleRegistryComponent', () => {
226278
});
227279
});
228280
});
281+
282+
it('should render the self delete button as disabled', () => {
283+
const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton'));
284+
285+
expect(deleteButtons.length).toBe(2);
286+
expect(deleteButtons[0].nativeElement.disabled).toBeFalse();
287+
expect(deleteButtons[1].nativeElement.disabled).toBeTrue();
288+
});
289+
290+
it('should call submitter checks and compose the combined warning label', fakeAsync(() => {
291+
workspaceItemDataService.searchBy.and.returnValue(buildRemoteList([{} as any], 1));
292+
workflowItemDataService.searchBy.and.returnValue(buildRemoteList([], 0));
293+
searchService.search.and.returnValue(createSuccessfulRemoteDataObject$(buildSearchObjects(0)));
294+
// isAuthorized returns true by default -> the target is treated as an administrator
295+
modalRef.componentInstance.response = observableOf(false);
296+
297+
const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton'));
298+
deleteButtons[0].triggerEventHandler('click', null);
299+
tick();
300+
301+
expect(workspaceItemDataService.searchBy).toHaveBeenCalledWith('findBySubmitter', jasmine.any(FindListOptions));
302+
expect(workflowItemDataService.searchBy).toHaveBeenCalledWith('findBySubmitter', jasmine.any(FindListOptions));
303+
expect(searchService.search).toHaveBeenCalled();
304+
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.AdministratorOf, undefined, EPersonMock.id);
305+
expect(modalRef.componentInstance.warningLabel).toBe('admin.access-control.epeople.delete.warning.submitterAndAdmin');
306+
}));
307+
308+
it('should detect administrator via the authorization feature', fakeAsync(() => {
309+
workspaceItemDataService.searchBy.and.returnValue(buildRemoteList([], 0));
310+
workflowItemDataService.searchBy.and.returnValue(buildRemoteList([], 0));
311+
searchService.search.and.returnValue(createSuccessfulRemoteDataObject$(buildSearchObjects(0)));
312+
// admin -> true, all submitter probes empty -> admin-only warning
313+
(authorizationService.isAuthorized as jasmine.Spy).and.callFake((featureId: FeatureID) => observableOf(featureId === FeatureID.AdministratorOf));
314+
modalRef.componentInstance.response = observableOf(false);
315+
316+
const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton'));
317+
deleteButtons[0].triggerEventHandler('click', null);
318+
tick();
319+
320+
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.AdministratorOf, undefined, EPersonMock.id);
321+
expect(modalRef.componentInstance.warningLabel).toBe('admin.access-control.epeople.delete.warning.admin');
322+
}));
323+
324+
it('should still open the delete modal when a submitter probe errors (centralised catchError)', fakeAsync(() => {
325+
workspaceItemDataService.searchBy.and.returnValue(observableThrowError(() => new Error('boom')));
326+
workflowItemDataService.searchBy.and.returnValue(buildRemoteList([], 0));
327+
searchService.search.and.returnValue(createSuccessfulRemoteDataObject$(buildSearchObjects(0)));
328+
// CanDelete stays true so the button renders; AdministratorOf false so the only warning could come from submitter probes
329+
(authorizationService.isAuthorized as jasmine.Spy).and.callFake((featureId: FeatureID) => observableOf(featureId !== FeatureID.AdministratorOf));
330+
modalRef.componentInstance.response = observableOf(false);
331+
332+
const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton'));
333+
deleteButtons[0].triggerEventHandler('click', null);
334+
tick();
335+
336+
expect(modalService.open).toHaveBeenCalled();
337+
expect(modalRef.componentInstance.warningLabel).toBeUndefined();
338+
}));
339+
340+
it('should show a friendly self-delete notification on backend 400 self-delete errors', fakeAsync(() => {
341+
modalRef.componentInstance.response = observableOf(true);
342+
ePersonDataServiceStub.deleteEPerson = jasmine.createSpy('deleteEPerson').and.returnValue(
343+
createFailedRemoteDataObject$('You, as admin user, cannot delete yourself', 400)
344+
);
345+
346+
const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton'));
347+
deleteButtons[0].triggerEventHandler('click', null);
348+
tick();
349+
350+
expect(notificationsService.error).toHaveBeenCalled();
351+
let translatedKey: string;
352+
notificationsService.error.calls.mostRecent().args[0].subscribe((value) => translatedKey = value);
353+
expect(translatedKey).toBe('admin.access-control.epeople.notification.deleted.forbidden.self');
354+
}));
355+
356+
it('should use the deleted.failure key for generic delete failures', fakeAsync(() => {
357+
modalRef.componentInstance.response = observableOf(true);
358+
ePersonDataServiceStub.deleteEPerson = jasmine.createSpy('deleteEPerson').and.returnValue(
359+
createFailedRemoteDataObject$('server error', 500)
360+
);
361+
362+
const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton'));
363+
deleteButtons[0].triggerEventHandler('click', null);
364+
tick();
365+
366+
expect(notificationsService.error).toHaveBeenCalled();
367+
let translatedKey: string;
368+
notificationsService.error.calls.mostRecent().args[0].subscribe((value) => translatedKey = value);
369+
expect(translatedKey).toBe('admin.access-control.epeople.notification.deleted.failure');
370+
}));
371+
372+
it('should not open delete modal before authenticated user id is resolved', fakeAsync(() => {
373+
component.currentAuthenticatedUserId = undefined;
374+
const deleteSpy = spyOn(ePersonDataServiceStub, 'deleteEPerson').and.callThrough();
375+
376+
const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton'));
377+
deleteButtons[0].triggerEventHandler('click', null);
378+
tick();
379+
380+
expect(modalService.open).not.toHaveBeenCalled();
381+
expect(deleteSpy).not.toHaveBeenCalled();
382+
}));
383+
384+
it('should still show the friendly self-delete notification if the authenticated user id resolves late and the backend rejection carries no usable message', fakeAsync(() => {
385+
modalRef.componentInstance.response = observableOf(true);
386+
component.currentAuthenticatedUserId = EPersonMock.id;
387+
ePersonDataServiceStub.deleteEPerson = jasmine.createSpy('deleteEPerson').and.returnValue(defer(() => {
388+
component.currentAuthenticatedUserId = EPersonMock2.id;
389+
return createFailedRemoteDataObject$(undefined, 400);
390+
}));
391+
392+
component.deleteEPerson(EPersonMock2);
393+
tick();
394+
395+
expect(notificationsService.error).toHaveBeenCalled();
396+
let translatedKey: string;
397+
notificationsService.error.calls.mostRecent().args[0].subscribe((value) => translatedKey = value);
398+
expect(translatedKey).toBe('admin.access-control.epeople.notification.deleted.forbidden.self');
399+
}));
229400
});
230401

231402
describe('delete EPerson button when the isAuthorized returns false', () => {
@@ -236,11 +407,9 @@ describe('EPeopleRegistryComponent', () => {
236407
fixture.detectChanges();
237408
});
238409

239-
it('should be disabled', () => {
410+
it('should be hidden', () => {
240411
ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button'));
241-
ePeopleDeleteButton.forEach((deleteButton: DebugElement) => {
242-
expect(deleteButton.nativeElement.disabled).toBe(true);
243-
});
412+
expect(ePeopleDeleteButton.length).toBe(0);
244413
});
245414
});
246415
});

0 commit comments

Comments
 (0)