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
-
-
- 0" class="list-group" formGroupName="groupsGroup">
-
- -
-
-
-
- -
-
-
-
-
- No groups defined
-
-
-
-
-
-
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
+
+
+
+
+ -
+
+
Username
+
Name
+
Groups
+
Actions
+
+
+
+ -
+
+
+
{{user.first_name}} {{user.last_name}}
+
{{user.groups?.map(getGroupName, this).join(', ')}}
+
+
+
+
+
+
+
+
+ Groups
+
+
+ 0" class="list-group">
+
+ -
+
+
+
+ -
+
+
+
+
+ No groups defined
+
+
+
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
+
+
+ Users & Groups
+
+
0 && slimSidebarEnabled" class="badge bg-danger position-absolute top-0 end-0">{{tasksService.failedFileTasks.length}}