From e1311d51f89645064c1261a2fea917659b29474e Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 22 Sep 2023 15:29:52 -0700 Subject: [PATCH] Move users and groups to its own component --- src-ui/e2e/admin/settings.spec.ts | 22 -- src-ui/src/app/app-routing.module.ts | 17 ++ src-ui/src/app/app.component.ts | 2 +- src-ui/src/app/app.module.ts | 4 +- .../admin/settings/settings.component.html | 86 ------ .../admin/settings/settings.component.spec.ts | 130 +-------- .../admin/settings/settings.component.ts | 179 ------------ .../users-groups/users-groups.component.html | 83 ++++++ .../users-groups/users-groups.component.scss | 0 .../users-groups.component.spec.ts | 267 ++++++++++++++++++ .../users-groups/users-groups.component.ts | 189 +++++++++++++ .../app-frame/app-frame.component.html | 7 + 12 files changed, 572 insertions(+), 414 deletions(-) create mode 100644 src-ui/src/app/components/admin/users-groups/users-groups.component.html create mode 100644 src-ui/src/app/components/admin/users-groups/users-groups.component.scss create mode 100644 src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts create mode 100644 src-ui/src/app/components/admin/users-groups/users-groups.component.ts diff --git a/src-ui/e2e/admin/settings.spec.ts b/src-ui/e2e/admin/settings.spec.ts index 00bdde029..92c6918d9 100644 --- a/src-ui/e2e/admin/settings.spec.ts +++ b/src-ui/e2e/admin/settings.spec.ts @@ -54,25 +54,3 @@ test('should toggle saved view options when set & saved', async ({ page }) => { await page.getByRole('button', { name: 'Save' }).click() await updatePromise }) - -test('should support tab direct navigation', async ({ page }) => { - await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) - await page.goto('/settings/general') - await expect(page.getByRole('tab', { name: 'General' })).toHaveAttribute( - 'aria-selected', - 'true' - ) - await page.goto('/settings/notifications') - await expect( - page.getByRole('tab', { name: 'Notifications' }) - ).toHaveAttribute('aria-selected', 'true') - await page.goto('/settings/savedviews') - await expect(page.getByRole('tab', { name: 'Saved Views' })).toHaveAttribute( - 'aria-selected', - 'true' - ) - await page.goto('/settings/usersgroups') - await expect( - page.getByRole('tab', { name: 'Users & Groups' }) - ).toHaveAttribute('aria-selected', 'true') -}) diff --git a/src-ui/src/app/app-routing.module.ts b/src-ui/src/app/app-routing.module.ts index 95a4c9f24..422098b19 100644 --- a/src-ui/src/app/app-routing.module.ts +++ b/src-ui/src/app/app-routing.module.ts @@ -23,6 +23,7 @@ import { } from './services/permissions.service' import { ConsumptionTemplatesListComponent } from './components/manage/consumption-templates-list/consumption-templates-list.component' import { MailComponent } from './components/manage/mail/mail.component' +import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component' export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, @@ -144,10 +145,15 @@ export const routes: Routes = [ }, }, }, + // redirect old paths { path: 'settings/mail', redirectTo: '/mail', }, + { + path: 'settings/usersgroups', + redirectTo: '/usersgroups', + }, { path: 'settings', component: SettingsComponent, @@ -205,6 +211,17 @@ export const routes: Routes = [ }, }, }, + { + path: 'usersgroups', + component: UsersAndGroupsComponent, + canActivate: [PermissionsGuard], + data: { + requiredPermission: { + action: PermissionAction.View, + type: PermissionType.User, + }, + }, + }, ], }, diff --git a/src-ui/src/app/app.component.ts b/src-ui/src/app/app.component.ts index 1bed38752..97dcdd930 100644 --- a/src-ui/src/app/app.component.ts +++ b/src-ui/src/app/app.component.ts @@ -197,7 +197,7 @@ export class AppComponent implements OnInit, OnDestroy { }, { anchorId: 'tour.settings', - content: $localize`Check out the settings for various tweaks to the web app, toggle settings for saved views or manage users.`, + content: $localize`Check out the settings for various tweaks to the web app and toggle settings for saved views.`, route: '/settings', backdropConfig: { offset: 0, diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index c2313dadd..758e531d4 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -97,6 +97,8 @@ import { IsNumberPipe } from './pipes/is-number.pipe' import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component' import { ConsumptionTemplatesListComponent } from './components/manage/consumption-templates-list/consumption-templates-list.component' import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component' +import { MailComponent } from './components/manage/mail/mail.component' +import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component' import localeAf from '@angular/common/locales/af' import localeAr from '@angular/common/locales/ar' @@ -125,7 +127,6 @@ import localeSv from '@angular/common/locales/sv' import localeTr from '@angular/common/locales/tr' import localeUk from '@angular/common/locales/uk' import localeZh from '@angular/common/locales/zh' -import { MailComponent } from './components/manage/mail/mail.component' registerLocaleData(localeAf) registerLocaleData(localeAr) @@ -239,6 +240,7 @@ function initializeApp(settings: SettingsService) { ConsumptionTemplatesListComponent, ConsumptionTemplateEditDialogComponent, MailComponent, + UsersAndGroupsComponent, ], imports: [ BrowserModule, diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html index ad411aab7..eea93b937 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -310,92 +310,6 @@ - -
  • - Users & Groups - - - -

    - Users - -

    -
      - -
    • -
      -
      Username
      -
      Name
      -
      Groups
      -
      Actions
      -
      -
    • - -
    • -
      -
      -
      {{user.first_name}} {{user.last_name}}
      -
      {{user.groups?.map(getGroupName, this).join(', ')}}
      -
      -
      - - -
      -
      -
      -
    • -
    - -

    - Groups - -

    -
      - -
    • -
      -
      Name
      -
      -
      -
      Actions
      -
      -
    • - -
    • -
      -
      -
      -
      -
      -
      - - -
      -
      -
      -
    • -
    - -
    No groups defined
    -
    - -
    -
    -
    Loading...
    -
    - -
    -
  • diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index 8418e4e12..c93532443 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -1,21 +1,14 @@ import { ViewportScroller, DatePipe } from '@angular/common' import { HttpClientTestingModule } from '@angular/common/http/testing' -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, -} from '@angular/core/testing' +import { ComponentFixture, TestBed } from '@angular/core/testing' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { By } from '@angular/platform-browser' import { Router, ActivatedRoute, convertToParamMap } from '@angular/router' import { RouterTestingModule } from '@angular/router/testing' import { - NgbModal, NgbModule, NgbAlertModule, NgbNavLink, - NgbModalRef, } from '@ng-bootstrap/ng-bootstrap' import { NgSelectModule } from '@ng-select/ng-select' import { of, throwError } from 'rxjs' @@ -33,12 +26,9 @@ import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService, Toast } from 'src/app/services/toast.service' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' -import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component' -import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component' import { CheckComponent } from '../../common/input/check/check.component' import { ColorComponent } from '../../common/input/color/color.component' import { NumberComponent } from '../../common/input/number/number.component' -import { PasswordComponent } from '../../common/input/password/password.component' import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component' import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component' import { SelectComponent } from '../../common/input/select/select.component' @@ -64,7 +54,6 @@ const groups = [ describe('SettingsComponent', () => { let component: SettingsComponent let fixture: ComponentFixture - let modalService: NgbModal let router: Router let settingsService: SettingsService let savedViewService: SavedViewService @@ -88,7 +77,6 @@ describe('SettingsComponent', () => { SafeHtmlPipe, SelectComponent, TextComponent, - PasswordComponent, NumberComponent, TagsComponent, PermissionsUserComponent, @@ -107,7 +95,6 @@ describe('SettingsComponent', () => { ], }).compileComponents() - modalService = TestBed.inject(NgbModal) router = TestBed.inject(Router) activatedRoute = TestBed.inject(ActivatedRoute) viewportScroller = TestBed.inject(ViewportScroller) @@ -169,8 +156,6 @@ describe('SettingsComponent', () => { expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications']) tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click')) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'savedviews']) - tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) - expect(navigateSpy).toHaveBeenCalledWith(['settings', 'usersgroups']) const initSpy = jest.spyOn(component, 'initialize') component.isDirty = true // mock dirty @@ -189,13 +174,13 @@ describe('SettingsComponent', () => { completeSetup() jest .spyOn(activatedRoute, 'paramMap', 'get') - .mockReturnValue(of(convertToParamMap({ section: 'usersgroups' }))) - activatedRoute.snapshot.fragment = '#usersgroups' + .mockReturnValue(of(convertToParamMap({ section: 'notifications' }))) + activatedRoute.snapshot.fragment = '#notifications' const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') component.ngOnInit() - expect(component.activeNavID).toEqual(4) // Users & Groups + expect(component.activeNavID).toEqual(2) // Users & Groups component.ngAfterViewInit() - expect(scrollSpy).toHaveBeenCalledWith('#usersgroups') + expect(scrollSpy).toHaveBeenCalledWith('#notifications') }) it('should support save saved views, show error', () => { @@ -299,107 +284,6 @@ describe('SettingsComponent', () => { ) }) - it('should support edit / create user, show error if needed', () => { - completeSetup() - let modal: NgbModalRef - modalService.activeInstances.subscribe((refs) => (modal = refs[0])) - component.editUser(users[0]) - const editDialog = modal.componentInstance as UserEditDialogComponent - const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') - editDialog.failed.emit() - expect(toastErrorSpy).toBeCalled() - settingsService.currentUser = users[1] // simulate logged in as different user - editDialog.succeeded.emit(users[0]) - expect(toastInfoSpy).toHaveBeenCalledWith( - `Saved user "${users[0].username}".` - ) - }) - - it('should support delete user, show error if needed', () => { - completeSetup() - let modal: NgbModalRef - modalService.activeInstances.subscribe((refs) => (modal = refs[0])) - component.deleteUser(users[0]) - const deleteDialog = modal.componentInstance as ConfirmDialogComponent - const deleteSpy = jest.spyOn(userService, 'delete') - const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') - const listAllSpy = jest.spyOn(userService, 'listAll') - deleteSpy.mockReturnValueOnce( - throwError(() => new Error('error deleting user')) - ) - deleteDialog.confirm() - expect(toastErrorSpy).toBeCalled() - deleteSpy.mockReturnValueOnce(of(true)) - deleteDialog.confirm() - expect(listAllSpy).toHaveBeenCalled() - expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user') - }) - - it('should logout current user if password changed, after delay', fakeAsync(() => { - completeSetup() - let modal: NgbModalRef - modalService.activeInstances.subscribe((refs) => (modal = refs[0])) - component.editUser(users[0]) - const editDialog = modal.componentInstance as UserEditDialogComponent - editDialog.passwordIsSet = true - settingsService.currentUser = users[0] // simulate logged in as same user - editDialog.succeeded.emit(users[0]) - fixture.detectChanges() - Object.defineProperty(window, 'location', { - value: { - href: 'http://localhost/', - }, - writable: true, // possibility to override - }) - tick(2600) - expect(window.location.href).toContain('logout') - })) - - it('should support edit / create group, show error if needed', () => { - completeSetup() - let modal: NgbModalRef - modalService.activeInstances.subscribe((refs) => (modal = refs[0])) - component.editGroup(groups[0]) - const editDialog = modal.componentInstance as GroupEditDialogComponent - const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') - editDialog.failed.emit() - expect(toastErrorSpy).toBeCalled() - editDialog.succeeded.emit(groups[0]) - expect(toastInfoSpy).toHaveBeenCalledWith( - `Saved group "${groups[0].name}".` - ) - }) - - it('should support delete group, show error if needed', () => { - completeSetup() - let modal: NgbModalRef - modalService.activeInstances.subscribe((refs) => (modal = refs[0])) - component.deleteGroup(users[0]) - const deleteDialog = modal.componentInstance as ConfirmDialogComponent - const deleteSpy = jest.spyOn(groupService, 'delete') - const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') - const listAllSpy = jest.spyOn(groupService, 'listAll') - deleteSpy.mockReturnValueOnce( - throwError(() => new Error('error deleting group')) - ) - deleteDialog.confirm() - expect(toastErrorSpy).toBeCalled() - deleteSpy.mockReturnValueOnce(of(true)) - deleteDialog.confirm() - expect(listAllSpy).toHaveBeenCalled() - expect(toastInfoSpy).toHaveBeenCalledWith('Deleted group') - }) - - it('should get group name', () => { - completeSetup() - expect(component.getGroupName(1)).toEqual(groups[0].name) - expect(component.getGroupName(11)).toEqual('') - }) - it('should show errors on load if load users failure', () => { const toastErrorSpy = jest.spyOn(toastService, 'showError') jest @@ -408,8 +292,6 @@ describe('SettingsComponent', () => { throwError(() => new Error('failed to load users')) ) completeSetup(userService) - const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) - tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab fixture.detectChanges() expect(toastErrorSpy).toBeCalled() }) @@ -422,8 +304,6 @@ describe('SettingsComponent', () => { throwError(() => new Error('failed to load groups')) ) completeSetup(groupService) - const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) - tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab fixture.detectChanges() expect(toastErrorSpy).toBeCalled() }) diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts index 953840d02..85376792b 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -50,7 +50,6 @@ enum SettingsNavIDs { General = 1, Notifications = 2, SavedViews = 3, - UsersGroups = 4, } @Component({ @@ -66,8 +65,6 @@ export class SettingsComponent activeNavID: number savedViewGroup = new FormGroup({}) - usersGroup = new FormGroup({}) - groupsGroup = new FormGroup({}) settingsForm = new FormGroup({ bulkEditConfirmationDialogs: new FormControl(null), @@ -94,8 +91,6 @@ export class SettingsComponent notificationsConsumerSuccess: new FormControl(null), notificationsConsumerFailed: new FormControl(null), notificationsConsumerSuppressOnDashboard: new FormControl(null), - usersGroup: this.usersGroup, - groupsGroup: this.groupsGroup, savedViewsWarnOnUnsavedChange: new FormControl(null), savedViews: this.savedViewGroup, @@ -133,7 +128,6 @@ export class SettingsComponent private usersService: UserService, private groupsService: GroupService, private router: Router, - private modalService: NgbModal, public permissionsService: PermissionsService ) { super() @@ -274,8 +268,6 @@ export class SettingsComponent defaultPermsEditGroups: this.settings.get( SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS ), - usersGroup: {}, - groupsGroup: {}, savedViews: {}, } } @@ -326,55 +318,6 @@ export class SettingsComponent } } - if (this.users) { - this.emptyGroup(this.usersGroup) - - for (let user of this.users) { - storeData.usersGroup[user.id.toString()] = { - id: user.id, - username: user.username, - first_name: user.first_name, - last_name: user.last_name, - is_active: user.is_active, - is_superuser: user.is_superuser, - groups: user.groups, - user_permissions: user.user_permissions, - } - this.usersGroup.addControl( - user.id.toString(), - new FormGroup({ - id: new FormControl(null), - username: new FormControl(null), - first_name: new FormControl(null), - last_name: new FormControl(null), - is_active: new FormControl(null), - is_superuser: new FormControl(null), - groups: new FormControl(null), - user_permissions: new FormControl(null), - }) - ) - } - } - - if (this.groups) { - this.emptyGroup(this.groupsGroup) - for (let group of this.groups) { - storeData.groupsGroup[group.id.toString()] = { - id: group.id, - name: group.name, - permissions: group.permissions, - } - this.groupsGroup.addControl( - group.id.toString(), - new FormGroup({ - id: new FormControl(null), - name: new FormControl(null), - permissions: new FormControl(null), - }) - ) - } - } - this.store = new BehaviorSubject(storeData) this.storeSub = this.store.asObservable().subscribe((state) => { @@ -619,126 +562,4 @@ export class SettingsComponent userIsOwner(obj: ObjectWithPermissions): boolean { return this.permissionsService.currentUserOwnsObject(obj) } - - editUser(user: PaperlessUser) { - var modal = this.modalService.open(UserEditDialogComponent, { - backdrop: 'static', - size: 'xl', - }) - modal.componentInstance.dialogMode = user - ? EditDialogMode.EDIT - : EditDialogMode.CREATE - modal.componentInstance.object = user - modal.componentInstance.succeeded - .pipe(takeUntil(this.unsubscribeNotifier)) - .subscribe((newUser: PaperlessUser) => { - if ( - newUser.id === this.settings.currentUser.id && - (modal.componentInstance as UserEditDialogComponent).passwordIsSet - ) { - this.toastService.showInfo( - $localize`Password has been changed, you will be logged out momentarily.` - ) - setTimeout(() => { - window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/` - }, 2500) - } else { - this.toastService.showInfo( - $localize`Saved user "${newUser.username}".` - ) - this.usersService.listAll().subscribe((r) => { - this.users = r.results - this.initialize() - }) - } - }) - modal.componentInstance.failed - .pipe(takeUntil(this.unsubscribeNotifier)) - .subscribe((e) => { - this.toastService.showError($localize`Error saving user.`, e) - }) - } - - deleteUser(user: PaperlessUser) { - let modal = this.modalService.open(ConfirmDialogComponent, { - backdrop: 'static', - }) - modal.componentInstance.title = $localize`Confirm delete user account` - modal.componentInstance.messageBold = $localize`This operation will permanently delete this user account.` - modal.componentInstance.message = $localize`This operation cannot be undone.` - modal.componentInstance.btnClass = 'btn-danger' - modal.componentInstance.btnCaption = $localize`Proceed` - modal.componentInstance.confirmClicked.subscribe(() => { - modal.componentInstance.buttonsEnabled = false - this.usersService.delete(user).subscribe({ - next: () => { - modal.close() - this.toastService.showInfo($localize`Deleted user`) - this.usersService.listAll().subscribe((r) => { - this.users = r.results - this.initialize(true) - }) - }, - error: (e) => { - this.toastService.showError($localize`Error deleting user.`, e) - }, - }) - }) - } - - editGroup(group: PaperlessGroup) { - var modal = this.modalService.open(GroupEditDialogComponent, { - backdrop: 'static', - size: 'lg', - }) - modal.componentInstance.dialogMode = group - ? EditDialogMode.EDIT - : EditDialogMode.CREATE - modal.componentInstance.object = group - modal.componentInstance.succeeded - .pipe(takeUntil(this.unsubscribeNotifier)) - .subscribe((newGroup) => { - this.toastService.showInfo($localize`Saved group "${newGroup.name}".`) - this.groupsService.listAll().subscribe((r) => { - this.groups = r.results - this.initialize() - }) - }) - modal.componentInstance.failed - .pipe(takeUntil(this.unsubscribeNotifier)) - .subscribe((e) => { - this.toastService.showError($localize`Error saving group.`, e) - }) - } - - deleteGroup(group: PaperlessGroup) { - let modal = this.modalService.open(ConfirmDialogComponent, { - backdrop: 'static', - }) - modal.componentInstance.title = $localize`Confirm delete user group` - modal.componentInstance.messageBold = $localize`This operation will permanently delete this user group.` - modal.componentInstance.message = $localize`This operation cannot be undone.` - modal.componentInstance.btnClass = 'btn-danger' - modal.componentInstance.btnCaption = $localize`Proceed` - modal.componentInstance.confirmClicked.subscribe(() => { - modal.componentInstance.buttonsEnabled = false - this.groupsService.delete(group).subscribe({ - next: () => { - modal.close() - this.toastService.showInfo($localize`Deleted group`) - this.groupsService.listAll().subscribe((r) => { - this.groups = r.results - this.initialize(true) - }) - }, - error: (e) => { - this.toastService.showError($localize`Error deleting group.`, e) - }, - }) - }) - } - - getGroupName(id: number): string { - return this.groups?.find((g) => g.id === id)?.name ?? '' - } } diff --git a/src-ui/src/app/components/admin/users-groups/users-groups.component.html b/src-ui/src/app/components/admin/users-groups/users-groups.component.html new file mode 100644 index 000000000..874a3fa8d --- /dev/null +++ b/src-ui/src/app/components/admin/users-groups/users-groups.component.html @@ -0,0 +1,83 @@ + + + + +

    + Users + +

    + +
    + + +

    + Groups + +

    + + +
    No groups defined
    +
    + +
    +
    +
    Loading...
    +
    diff --git a/src-ui/src/app/components/admin/users-groups/users-groups.component.scss b/src-ui/src/app/components/admin/users-groups/users-groups.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts b/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts new file mode 100644 index 000000000..fd8961d51 --- /dev/null +++ b/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts @@ -0,0 +1,267 @@ +import { DatePipe } from '@angular/common' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { RouterTestingModule } from '@angular/router/testing' +import { + NgbModule, + NgbAlertModule, + NgbModal, + NgbModalRef, +} from '@ng-bootstrap/ng-bootstrap' +import { NgSelectModule } from '@ng-select/ng-select' +import { throwError, of } from 'rxjs' +import { routes } from 'src/app/app-routing.module' +import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' +import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' +import { PermissionsGuard } from 'src/app/guards/permissions.guard' +import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' +import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' +import { PermissionsService } from 'src/app/services/permissions.service' +import { GroupService } from 'src/app/services/rest/group.service' +import { UserService } from 'src/app/services/rest/user.service' +import { SettingsService } from 'src/app/services/settings.service' +import { ToastService } from 'src/app/services/toast.service' +import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' +import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component' +import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component' +import { CheckComponent } from '../../common/input/check/check.component' +import { NumberComponent } from '../../common/input/number/number.component' +import { PasswordComponent } from '../../common/input/password/password.component' +import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component' +import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component' +import { SelectComponent } from '../../common/input/select/select.component' +import { TagsComponent } from '../../common/input/tags/tags.component' +import { TextComponent } from '../../common/input/text/text.component' +import { PageHeaderComponent } from '../../common/page-header/page-header.component' +import { SettingsComponent } from '../settings/settings.component' +import { UsersAndGroupsComponent } from './users-groups.component' +import { PaperlessUser } from 'src/app/data/paperless-user' +import { PaperlessGroup } from 'src/app/data/paperless-group' + +const users = [ + { id: 1, username: 'user1', is_superuser: false }, + { id: 2, username: 'user2', is_superuser: false }, +] +const groups = [ + { id: 1, name: 'group1' }, + { id: 2, name: 'group2' }, +] + +describe('UsersAndGroupsComponent', () => { + let component: UsersAndGroupsComponent + let fixture: ComponentFixture + let settingsService: SettingsService + let modalService: NgbModal + let toastService: ToastService + let userService: UserService + let permissionsService: PermissionsService + let groupService: GroupService + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + UsersAndGroupsComponent, + SettingsComponent, + PageHeaderComponent, + IfPermissionsDirective, + CustomDatePipe, + ConfirmDialogComponent, + CheckComponent, + SafeHtmlPipe, + SelectComponent, + TextComponent, + PasswordComponent, + NumberComponent, + TagsComponent, + PermissionsUserComponent, + PermissionsGroupComponent, + IfOwnerDirective, + ], + providers: [CustomDatePipe, DatePipe, PermissionsGuard], + imports: [ + NgbModule, + HttpClientTestingModule, + RouterTestingModule.withRoutes(routes), + FormsModule, + ReactiveFormsModule, + NgbAlertModule, + NgSelectModule, + ], + }).compileComponents() + fixture = TestBed.createComponent(UsersAndGroupsComponent) + settingsService = TestBed.inject(SettingsService) + settingsService.currentUser = users[0] + userService = TestBed.inject(UserService) + modalService = TestBed.inject(NgbModal) + toastService = TestBed.inject(ToastService) + permissionsService = TestBed.inject(PermissionsService) + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + jest + .spyOn(permissionsService, 'currentUserHasObjectPermissions') + .mockReturnValue(true) + jest + .spyOn(permissionsService, 'currentUserOwnsObject') + .mockReturnValue(true) + groupService = TestBed.inject(GroupService) + component = fixture.componentInstance + fixture.detectChanges() + }) + + function completeSetup(excludeService = null) { + if (excludeService !== userService) { + jest.spyOn(userService, 'listAll').mockReturnValue( + of({ + all: users.map((a) => a.id), + count: users.length, + results: (users as PaperlessUser[]).concat([]), + }) + ) + } + if (excludeService !== groupService) { + jest.spyOn(groupService, 'listAll').mockReturnValue( + of({ + all: groups.map((r) => r.id), + count: groups.length, + results: (groups as PaperlessGroup[]).concat([]), + }) + ) + } + + fixture = TestBed.createComponent(UsersAndGroupsComponent) + component = fixture.componentInstance + fixture.detectChanges() + } + + it('should support edit / create user, show error if needed', () => { + completeSetup() + let modal: NgbModalRef + modalService.activeInstances.subscribe((refs) => (modal = refs[0])) + component.editUser(users[0]) + const editDialog = modal.componentInstance as UserEditDialogComponent + const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + editDialog.failed.emit() + expect(toastErrorSpy).toBeCalled() + settingsService.currentUser = users[1] // simulate logged in as different user + editDialog.succeeded.emit(users[0]) + expect(toastInfoSpy).toHaveBeenCalledWith( + `Saved user "${users[0].username}".` + ) + component.editUser() + }) + + it('should support delete user, show error if needed', () => { + completeSetup() + let modal: NgbModalRef + modalService.activeInstances.subscribe((refs) => (modal = refs[0])) + component.deleteUser(users[0]) + const deleteDialog = modal.componentInstance as ConfirmDialogComponent + const deleteSpy = jest.spyOn(userService, 'delete') + const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const listAllSpy = jest.spyOn(userService, 'listAll') + deleteSpy.mockReturnValueOnce( + throwError(() => new Error('error deleting user')) + ) + deleteDialog.confirm() + expect(toastErrorSpy).toBeCalled() + deleteSpy.mockReturnValueOnce(of(true)) + deleteDialog.confirm() + expect(listAllSpy).toHaveBeenCalled() + expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user') + }) + + it('should logout current user if password changed, after delay', fakeAsync(() => { + completeSetup() + let modal: NgbModalRef + modalService.activeInstances.subscribe((refs) => (modal = refs[0])) + component.editUser(users[0]) + const editDialog = modal.componentInstance as UserEditDialogComponent + editDialog.passwordIsSet = true + settingsService.currentUser = users[0] // simulate logged in as same user + editDialog.succeeded.emit(users[0]) + fixture.detectChanges() + Object.defineProperty(window, 'location', { + value: { + href: 'http://localhost/', + }, + writable: true, // possibility to override + }) + tick(2600) + expect(window.location.href).toContain('logout') + })) + + it('should support edit / create group, show error if needed', () => { + completeSetup() + let modal: NgbModalRef + modalService.activeInstances.subscribe((refs) => (modal = refs[0])) + component.editGroup(groups[0]) + const editDialog = modal.componentInstance as GroupEditDialogComponent + const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + editDialog.failed.emit() + expect(toastErrorSpy).toBeCalled() + editDialog.succeeded.emit(groups[0]) + expect(toastInfoSpy).toHaveBeenCalledWith( + `Saved group "${groups[0].name}".` + ) + component.editGroup() + }) + + it('should support delete group, show error if needed', () => { + completeSetup() + let modal: NgbModalRef + modalService.activeInstances.subscribe((refs) => (modal = refs[0])) + component.deleteGroup(users[0]) + const deleteDialog = modal.componentInstance as ConfirmDialogComponent + const deleteSpy = jest.spyOn(groupService, 'delete') + const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const listAllSpy = jest.spyOn(groupService, 'listAll') + deleteSpy.mockReturnValueOnce( + throwError(() => new Error('error deleting group')) + ) + deleteDialog.confirm() + expect(toastErrorSpy).toBeCalled() + deleteSpy.mockReturnValueOnce(of(true)) + deleteDialog.confirm() + expect(listAllSpy).toHaveBeenCalled() + expect(toastInfoSpy).toHaveBeenCalledWith('Deleted group') + }) + + it('should get group name', () => { + completeSetup() + expect(component.getGroupName(1)).toEqual(groups[0].name) + expect(component.getGroupName(11)).toEqual('') + }) + + it('should show errors on load if load users failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(userService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load users')) + ) + completeSetup(userService) + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) + + it('should show errors on load if load groups failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(groupService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load groups')) + ) + completeSetup(groupService) + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) +}) diff --git a/src-ui/src/app/components/admin/users-groups/users-groups.component.ts b/src-ui/src/app/components/admin/users-groups/users-groups.component.ts new file mode 100644 index 000000000..a9ce1d600 --- /dev/null +++ b/src-ui/src/app/components/admin/users-groups/users-groups.component.ts @@ -0,0 +1,189 @@ +import { Component, OnDestroy, OnInit } from '@angular/core' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { Subject, first, takeUntil } from 'rxjs' +import { PaperlessGroup } from 'src/app/data/paperless-group' +import { PaperlessUser } from 'src/app/data/paperless-user' +import { PermissionsService } from 'src/app/services/permissions.service' +import { GroupService } from 'src/app/services/rest/group.service' +import { UserService } from 'src/app/services/rest/user.service' +import { ToastService } from 'src/app/services/toast.service' +import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' +import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' +import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component' +import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component' +import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' +import { SettingsService } from 'src/app/services/settings.service' + +@Component({ + selector: 'pngx-users-groups', + templateUrl: './users-groups.component.html', + styleUrls: ['./users-groups.component.scss'], +}) +export class UsersAndGroupsComponent + extends ComponentWithPermissions + implements OnInit, OnDestroy +{ + users: PaperlessUser[] + groups: PaperlessGroup[] + + unsubscribeNotifier: Subject = new Subject() + + constructor( + private usersService: UserService, + private groupsService: GroupService, + private toastService: ToastService, + private modalService: NgbModal, + public permissionsService: PermissionsService, + private settings: SettingsService + ) { + super() + } + + ngOnInit(): void { + this.usersService + .listAll(null, null, { full_perms: true }) + .pipe(first(), takeUntil(this.unsubscribeNotifier)) + .subscribe({ + next: (r) => { + this.users = r.results + }, + error: (e) => { + this.toastService.showError($localize`Error retrieving users`, e) + }, + }) + + this.groupsService + .listAll(null, null, { full_perms: true }) + .pipe(first(), takeUntil(this.unsubscribeNotifier)) + .subscribe({ + next: (r) => { + this.groups = r.results + }, + error: (e) => { + this.toastService.showError($localize`Error retrieving groups`, e) + }, + }) + } + + ngOnDestroy() { + this.unsubscribeNotifier.next(true) + } + + editUser(user: PaperlessUser = null) { + var modal = this.modalService.open(UserEditDialogComponent, { + backdrop: 'static', + size: 'xl', + }) + modal.componentInstance.dialogMode = user + ? EditDialogMode.EDIT + : EditDialogMode.CREATE + modal.componentInstance.object = user + modal.componentInstance.succeeded + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe((newUser: PaperlessUser) => { + if ( + newUser.id === this.settings.currentUser.id && + (modal.componentInstance as UserEditDialogComponent).passwordIsSet + ) { + this.toastService.showInfo( + $localize`Password has been changed, you will be logged out momentarily.` + ) + setTimeout(() => { + window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/` + }, 2500) + } else { + this.toastService.showInfo( + $localize`Saved user "${newUser.username}".` + ) + this.usersService.listAll().subscribe((r) => { + this.users = r.results + }) + } + }) + modal.componentInstance.failed + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe((e) => { + this.toastService.showError($localize`Error saving user.`, e) + }) + } + + deleteUser(user: PaperlessUser) { + let modal = this.modalService.open(ConfirmDialogComponent, { + backdrop: 'static', + }) + modal.componentInstance.title = $localize`Confirm delete user account` + modal.componentInstance.messageBold = $localize`This operation will permanently delete this user account.` + modal.componentInstance.message = $localize`This operation cannot be undone.` + modal.componentInstance.btnClass = 'btn-danger' + modal.componentInstance.btnCaption = $localize`Proceed` + modal.componentInstance.confirmClicked.subscribe(() => { + modal.componentInstance.buttonsEnabled = false + this.usersService.delete(user).subscribe({ + next: () => { + modal.close() + this.toastService.showInfo($localize`Deleted user`) + this.usersService.listAll().subscribe((r) => { + this.users = r.results + }) + }, + error: (e) => { + this.toastService.showError($localize`Error deleting user.`, e) + }, + }) + }) + } + + editGroup(group: PaperlessGroup = null) { + var modal = this.modalService.open(GroupEditDialogComponent, { + backdrop: 'static', + size: 'lg', + }) + modal.componentInstance.dialogMode = group + ? EditDialogMode.EDIT + : EditDialogMode.CREATE + modal.componentInstance.object = group + modal.componentInstance.succeeded + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe((newGroup) => { + this.toastService.showInfo($localize`Saved group "${newGroup.name}".`) + this.groupsService.listAll().subscribe((r) => { + this.groups = r.results + }) + }) + modal.componentInstance.failed + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe((e) => { + this.toastService.showError($localize`Error saving group.`, e) + }) + } + + deleteGroup(group: PaperlessGroup) { + let modal = this.modalService.open(ConfirmDialogComponent, { + backdrop: 'static', + }) + modal.componentInstance.title = $localize`Confirm delete user group` + modal.componentInstance.messageBold = $localize`This operation will permanently delete this user group.` + modal.componentInstance.message = $localize`This operation cannot be undone.` + modal.componentInstance.btnClass = 'btn-danger' + modal.componentInstance.btnCaption = $localize`Proceed` + modal.componentInstance.confirmClicked.subscribe(() => { + modal.componentInstance.buttonsEnabled = false + this.groupsService.delete(group).subscribe({ + next: () => { + modal.close() + this.toastService.showInfo($localize`Deleted group`) + this.groupsService.listAll().subscribe((r) => { + this.groups = r.results + }) + }, + error: (e) => { + this.toastService.showError($localize`Error deleting group.`, e) + }, + }) + }) + } + + getGroupName(id: number): string { + return this.groups?.find((g) => g.id === id)?.name ?? '' + } +} diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index c8567f4ae..b766da393 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -182,6 +182,13 @@  Settings +