mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Enhancement: settings reorganization & improvements, separate admin section (#4251)
* Separate admin / manage sections * Move mail settings to its own component * Move users and groups to its own component * Move default permissions to its own settings tab * Unify list styling, add tour step, refactor components * Only patch saved views that have changed on settings save * Update messages.xlf * Remove unused methods in settings.component.ts * Drop admin section to bottom of sidebar, cleanup outdated, add docs link to dropdown * Better visually unify management list & other list pages
This commit is contained in:
		| @@ -1,24 +1,6 @@ | ||||
| import { test, expect } from '@playwright/test' | ||||
| 
 | ||||
| const REQUESTS_HAR = 'e2e/settings/requests/api-settings.har' | ||||
| 
 | ||||
| test('should post settings on save', async ({ page }) => { | ||||
|   await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) | ||||
|   await page.goto('/settings') | ||||
|   await page.getByLabel('Use system setting').click() | ||||
|   await page.getByRole('button', { name: 'Save' }).scrollIntoViewIfNeeded() | ||||
|   const updatePromise = page.waitForRequest((request) => { | ||||
|     const data = request.postDataJSON() | ||||
|     const isValid = data['settings'] != null | ||||
|     return ( | ||||
|       isValid && | ||||
|       request.method() === 'POST' && | ||||
|       request.url().includes('/api/ui_settings/') | ||||
|     ) | ||||
|   }) | ||||
|   await page.getByRole('button', { name: 'Save' }).click() | ||||
|   await updatePromise | ||||
| }) | ||||
| const REQUESTS_HAR = 'e2e/admin/requests/api-settings.har' | ||||
| 
 | ||||
| test('should activate / deactivate save button when settings change', async ({ | ||||
|   page, | ||||
| @@ -72,30 +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/mail') | ||||
|   await expect(page.getByRole('tab', { name: 'Mail' })).toHaveAttribute( | ||||
|     'aria-selected', | ||||
|     'true' | ||||
|   ) | ||||
|   await page.goto('/settings/usersgroups') | ||||
|   await expect( | ||||
|     page.getByRole('tab', { name: 'Users & Groups' }) | ||||
|   ).toHaveAttribute('aria-selected', 'true') | ||||
| }) | ||||
							
								
								
									
										3444
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
							
						
						
									
										3444
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -6,14 +6,14 @@ import { DocumentDetailComponent } from './components/document-detail/document-d | ||||
| import { DocumentListComponent } from './components/document-list/document-list.component' | ||||
| import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component' | ||||
| import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component' | ||||
| import { LogsComponent } from './components/manage/logs/logs.component' | ||||
| import { SettingsComponent } from './components/manage/settings/settings.component' | ||||
| import { LogsComponent } from './components/admin/logs/logs.component' | ||||
| import { SettingsComponent } from './components/admin/settings/settings.component' | ||||
| import { TagListComponent } from './components/manage/tag-list/tag-list.component' | ||||
| import { NotFoundComponent } from './components/not-found/not-found.component' | ||||
| import { DocumentAsnComponent } from './components/document-asn/document-asn.component' | ||||
| import { DirtyFormGuard } from './guards/dirty-form.guard' | ||||
| import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | ||||
| import { TasksComponent } from './components/manage/tasks/tasks.component' | ||||
| import { TasksComponent } from './components/admin/tasks/tasks.component' | ||||
| import { PermissionsGuard } from './guards/permissions.guard' | ||||
| import { DirtyDocGuard } from './guards/dirty-doc.guard' | ||||
| import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' | ||||
| @@ -21,7 +21,9 @@ import { | ||||
|   PermissionAction, | ||||
|   PermissionType, | ||||
| } from './services/permissions.service' | ||||
| import { ConsumptionTemplatesListComponent } from './components/manage/consumption-templates-list/consumption-templates-list.component' | ||||
| import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.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' }, | ||||
| @@ -143,6 +145,15 @@ export const routes: Routes = [ | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       // redirect old paths | ||||
|       { | ||||
|         path: 'settings/mail', | ||||
|         redirectTo: '/mail', | ||||
|       }, | ||||
|       { | ||||
|         path: 'settings/usersgroups', | ||||
|         redirectTo: '/usersgroups', | ||||
|       }, | ||||
|       { | ||||
|         path: 'settings', | ||||
|         component: SettingsComponent, | ||||
| @@ -167,11 +178,6 @@ export const routes: Routes = [ | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         path: 'settings/:section', | ||||
|         component: SettingsComponent, | ||||
|         canDeactivate: [DirtyFormGuard], | ||||
|       }, | ||||
|       { | ||||
|         path: 'tasks', | ||||
|         component: TasksComponent, | ||||
| @@ -185,7 +191,7 @@ export const routes: Routes = [ | ||||
|       }, | ||||
|       { | ||||
|         path: 'templates', | ||||
|         component: ConsumptionTemplatesListComponent, | ||||
|         component: ConsumptionTemplatesComponent, | ||||
|         canActivate: [PermissionsGuard], | ||||
|         data: { | ||||
|           requiredPermission: { | ||||
| @@ -194,6 +200,28 @@ export const routes: Routes = [ | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         path: 'mail', | ||||
|         component: MailComponent, | ||||
|         canActivate: [PermissionsGuard], | ||||
|         data: { | ||||
|           requiredPermission: { | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.MailAccount, | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         path: 'usersgroups', | ||||
|         component: UsersAndGroupsComponent, | ||||
|         canActivate: [PermissionsGuard], | ||||
|         data: { | ||||
|           requiredPermission: { | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.User, | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
|  | ||||
|   | ||||
| @@ -179,6 +179,22 @@ export class AppComponent implements OnInit, OnDestroy { | ||||
|             offset: 0, | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           anchorId: 'tour.mail', | ||||
|           content: $localize`Manage e-mail accounts and rules for automatically importing documents.`, | ||||
|           route: '/mail', | ||||
|           backdropConfig: { | ||||
|             offset: 0, | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           anchorId: 'tour.consumption-templates', | ||||
|           content: $localize`Consumption templates give you finer control over the document ingestion process.`, | ||||
|           route: '/templates', | ||||
|           backdropConfig: { | ||||
|             offset: 0, | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           anchorId: 'tour.file-tasks', | ||||
|           content: $localize`File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.`, | ||||
| @@ -189,7 +205,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 setup e-mail checking.`, | ||||
|           content: $localize`Check out the settings for various tweaks to the web app and toggle settings for saved views.`, | ||||
|           route: '/settings', | ||||
|           backdropConfig: { | ||||
|             offset: 0, | ||||
|   | ||||
| @@ -14,8 +14,8 @@ import { DashboardComponent } from './components/dashboard/dashboard.component' | ||||
| import { TagListComponent } from './components/manage/tag-list/tag-list.component' | ||||
| import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component' | ||||
| import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component' | ||||
| import { LogsComponent } from './components/manage/logs/logs.component' | ||||
| import { SettingsComponent } from './components/manage/settings/settings.component' | ||||
| import { LogsComponent } from './components/admin/logs/logs.component' | ||||
| import { SettingsComponent } from './components/admin/settings/settings.component' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { DatePipe, registerLocaleData } from '@angular/common' | ||||
| import { NotFoundComponent } from './components/not-found/not-found.component' | ||||
| @@ -77,7 +77,7 @@ import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' | ||||
| import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | ||||
| import { StoragePathEditDialogComponent } from './components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' | ||||
| import { SettingsService } from './services/settings.service' | ||||
| import { TasksComponent } from './components/manage/tasks/tasks.component' | ||||
| import { TasksComponent } from './components/admin/tasks/tasks.component' | ||||
| import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' | ||||
| import { UserEditDialogComponent } from './components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component' | ||||
| import { GroupEditDialogComponent } from './components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component' | ||||
| @@ -95,8 +95,10 @@ import { UsernamePipe } from './pipes/username.pipe' | ||||
| import { LogoComponent } from './components/common/logo/logo.component' | ||||
| 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 { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.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' | ||||
| @@ -235,8 +237,10 @@ function initializeApp(settings: SettingsService) { | ||||
|     LogoComponent, | ||||
|     IsNumberPipe, | ||||
|     ShareLinksDropdownComponent, | ||||
|     ConsumptionTemplatesListComponent, | ||||
|     ConsumptionTemplatesComponent, | ||||
|     ConsumptionTemplateEditDialogComponent, | ||||
|     MailComponent, | ||||
|     UsersAndGroupsComponent, | ||||
|   ], | ||||
|   imports: [ | ||||
|     BrowserModule, | ||||
|   | ||||
| @@ -11,7 +11,7 @@ | ||||
| <form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> | ||||
| 
 | ||||
|   <ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs"> | ||||
|     <li [ngbNavItem]="SettingsNavIDs.General" (mouseover)="maybeInitializeTab(SettingsNavIDs.General)"> | ||||
|     <li [ngbNavItem]="SettingsNavIDs.General"> | ||||
|       <a ngbNavLink i18n>General</a> | ||||
|       <ng-template ngbNavContent> | ||||
| 
 | ||||
| @@ -132,7 +132,46 @@ | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <h4 i18n>Permissions</h4> | ||||
|         <h4 class="mt-4" id="update-checking" i18n>Update checking</h4> | ||||
| 
 | ||||
|         <div class="row mb-3"> | ||||
|           <div class="offset-md-3 col"> | ||||
|             <p i18n> | ||||
|               Update checking works by pinging the public <a href="https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest" target="_blank" rel="noopener noreferrer">GitHub API</a> for the latest release to determine whether a new version is available.<br/> | ||||
|               Actual updating of the app must still be performed manually. | ||||
|             </p> | ||||
|             <p i18n> | ||||
|               <em>No tracking data is collected by the app in any way.</em> | ||||
|             </p> | ||||
|             <pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <h4 class="mt-4" i18n>Bulk editing</h4> | ||||
| 
 | ||||
|         <div class="row mb-3"> | ||||
|           <div class="offset-md-3 col"> | ||||
|             <pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs" i18n-hint hint="Deleting documents will always ask for confirmation."></pngx-input-check> | ||||
|             <pngx-input-check i18n-title title="Apply on close" formControlName="bulkEditApplyOnClose"></pngx-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <h4 class="mt-4" i18n>Notes</h4> | ||||
| 
 | ||||
|         <div class="row mb-3"> | ||||
|           <div class="offset-md-3 col"> | ||||
|             <pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|       </ng-template> | ||||
|     </li> | ||||
| 
 | ||||
|     <li [ngbNavItem]="SettingsNavIDs.Permissions"> | ||||
|       <a ngbNavLink i18n>Permissions</a> | ||||
|       <ng-template ngbNavContent> | ||||
| 
 | ||||
|         <h4 i18n>Default Permissions</h4> | ||||
| 
 | ||||
|         <div class="row mb-3"> | ||||
|           <div class="offset-md-3 col"> | ||||
| @@ -207,39 +246,6 @@ | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <h4 class="mt-4" id="update-checking" i18n>Update checking</h4> | ||||
| 
 | ||||
|         <div class="row mb-3"> | ||||
|           <div class="offset-md-3 col"> | ||||
|             <p i18n> | ||||
|               Update checking works by pinging the public <a href="https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest" target="_blank" rel="noopener noreferrer">GitHub API</a> for the latest release to determine whether a new version is available.<br/> | ||||
|               Actual updating of the app must still be performed manually. | ||||
|             </p> | ||||
|             <p i18n> | ||||
|               <em>No tracking data is collected by the app in any way.</em> | ||||
|             </p> | ||||
|             <pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled" i18n-hint hint="Note that for users of third-party containers e.g. linuxserver.io this notification may be 'ahead' of the current third-party release."></pngx-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <h4 class="mt-4" i18n>Bulk editing</h4> | ||||
| 
 | ||||
|         <div class="row mb-3"> | ||||
|           <div class="offset-md-3 col"> | ||||
|             <pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs" i18n-hint hint="Deleting documents will always ask for confirmation."></pngx-input-check> | ||||
|             <pngx-input-check i18n-title title="Apply on close" formControlName="bulkEditApplyOnClose"></pngx-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <h4 class="mt-4" i18n>Notes</h4> | ||||
| 
 | ||||
|         <div class="row mb-3"> | ||||
|           <div class="offset-md-3 col"> | ||||
|             <pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|       </ng-template> | ||||
|     </li> | ||||
| 
 | ||||
| @@ -261,7 +267,7 @@ | ||||
|       </ng-template> | ||||
|     </li> | ||||
| 
 | ||||
|     <li [ngbNavItem]="SettingsNavIDs.SavedViews" (mouseover)="maybeInitializeTab(SettingsNavIDs.SavedViews)" (focusin)="maybeInitializeTab(SettingsNavIDs.SavedViews)"> | ||||
|     <li [ngbNavItem]="SettingsNavIDs.SavedViews"> | ||||
|       <a ngbNavLink i18n>Saved views</a> | ||||
|       <ng-template ngbNavContent> | ||||
| 
 | ||||
| @@ -310,182 +316,6 @@ | ||||
| 
 | ||||
|       </ng-template> | ||||
|     </li> | ||||
| 
 | ||||
|     <li *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }" [ngbNavItem]="SettingsNavIDs.Mail" (mouseover)="maybeInitializeTab(SettingsNavIDs.Mail)" (focusin)="maybeInitializeTab(SettingsNavIDs.Mail)"> | ||||
|       <a ngbNavLink i18n>Mail</a> | ||||
|       <ng-template ngbNavContent> | ||||
| 
 | ||||
|         <ng-container *ngIf="mailAccounts && mailRules"> | ||||
|           <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"> | ||||
|             <h4> | ||||
|               <ng-container i18n>Mail accounts</ng-container> | ||||
|               <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }"> | ||||
|                 <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|                   <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|                 </svg> | ||||
|                 <ng-container i18n>Add Account</ng-container> | ||||
|               </button> | ||||
|             </h4> | ||||
|             <ul class="list-group" formGroupName="mailAccounts"> | ||||
| 
 | ||||
|                 <li class="list-group-item"> | ||||
|                   <div class="row"> | ||||
|                     <div class="col" i18n>Name</div> | ||||
|                     <div class="col" i18n>Server</div> | ||||
|                     <div class="col" i18n>Actions</div> | ||||
|                   </div> | ||||
|                 </li> | ||||
| 
 | ||||
|                 <li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id"> | ||||
|                   <div class="row"> | ||||
|                     <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div> | ||||
|                     <div class="col d-flex align-items-center">{{account.imap_server}}</div> | ||||
|                     <div class="col"> | ||||
|                       <div class="btn-group"> | ||||
|                         <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" [disabled]="!userCanEdit(account)" class="btn btn-sm btn-primary" type="button" (click)="editMailAccount(account)" i18n>Edit</button> | ||||
|                         <button *pngxIfOwner="account" class="btn btn-sm btn-primary" type="button" (click)="editPermissions(account)" i18n>Permissions</button> | ||||
|                         <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailAccount }" [disabled]="!userIsOwner(account)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)" i18n>Delete</button> | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </li> | ||||
| 
 | ||||
|                 <div *ngIf="mailAccounts.length === 0" i18n>No mail accounts defined.</div> | ||||
|             </ul> | ||||
|           </ng-container> | ||||
| 
 | ||||
|           <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }"> | ||||
|             <h4 class="mt-4"> | ||||
|               <ng-container i18n>Mail rules</ng-container> | ||||
|               <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }"> | ||||
|                 <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|                   <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|                 </svg> | ||||
|                 <ng-container i18n>Add Rule</ng-container> | ||||
|               </button> | ||||
|             </h4> | ||||
|             <ul class="list-group" formGroupName="mailRules"> | ||||
| 
 | ||||
|                 <li class="list-group-item"> | ||||
|                   <div class="row"> | ||||
|                     <div class="col" i18n>Name</div> | ||||
|                     <div class="col" i18n>Account</div> | ||||
|                     <div class="col" i18n>Actions</div> | ||||
|                   </div> | ||||
|                 </li> | ||||
| 
 | ||||
|                 <li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id"> | ||||
|                   <div class="row"> | ||||
|                     <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div> | ||||
|                     <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> | ||||
|                     <div class="col"> | ||||
|                       <div class="btn-group"> | ||||
|                         <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" [disabled]="!userCanEdit(rule)" class="btn btn-sm btn-primary" type="button" (click)="editMailRule(rule)" i18n>Edit</button> | ||||
|                         <button *pngxIfOwner="rule" class="btn btn-sm btn-primary" type="button" (click)="editPermissions(rule)" i18n>Permissions</button> | ||||
|                         <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailRule }" [disabled]="!userIsOwner(rule)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)" i18n>Delete</button> | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </li> | ||||
| 
 | ||||
|                 <div *ngIf="mailRules.length === 0" i18n>No mail rules defined.</div> | ||||
|             </ul> | ||||
|           </ng-container> | ||||
|         </ng-container> | ||||
| 
 | ||||
|         <div *ngIf="!mailAccounts || !mailRules"> | ||||
|           <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||
|           <div class="visually-hidden" i18n>Loading...</div> | ||||
|         </div> | ||||
| 
 | ||||
|       </ng-template> | ||||
|     </li> | ||||
| 
 | ||||
|     <li [ngbNavItem]="SettingsNavIDs.UsersGroups" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" (mouseover)="maybeInitializeTab(SettingsNavIDs.UsersGroups)" (focusin)="maybeInitializeTab(SettingsNavIDs.UsersGroups)"> | ||||
|       <a ngbNavLink i18n>Users & Groups</a> | ||||
|       <ng-template ngbNavContent> | ||||
| 
 | ||||
|       <ng-container *ngIf="users && groups"> | ||||
|         <h4 class="d-flex"> | ||||
|           <ng-container i18n>Users</ng-container> | ||||
|           <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }"> | ||||
|             <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|               <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|             </svg> | ||||
|             <ng-container i18n>Add User</ng-container> | ||||
|           </button> | ||||
|         </h4> | ||||
|         <ul class="list-group" formGroupName="usersGroup"> | ||||
| 
 | ||||
|           <li class="list-group-item"> | ||||
|             <div class="row"> | ||||
|               <div class="col" i18n>Username</div> | ||||
|               <div class="col" i18n>Name</div> | ||||
|               <div class="col" i18n>Groups</div> | ||||
|               <div class="col" i18n>Actions</div> | ||||
|             </div> | ||||
|           </li> | ||||
| 
 | ||||
|           <li *ngFor="let user of users" class="list-group-item" [formGroupName]="user.id"> | ||||
|             <div class="row"> | ||||
|               <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div> | ||||
|               <div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div> | ||||
|               <div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div> | ||||
|               <div class="col"> | ||||
|                 <div class="btn-group"> | ||||
|                   <button class="btn btn-sm btn-primary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }" i18n>Edit</button> | ||||
|                   <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }" i18n>Delete</button> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </li> | ||||
|         </ul> | ||||
| 
 | ||||
|         <h4 class="mt-4 d-flex"> | ||||
|           <ng-container i18n>Groups</ng-container> | ||||
|           <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }"> | ||||
|             <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|               <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|             </svg> | ||||
|             <ng-container i18n>Add Group</ng-container> | ||||
|           </button> | ||||
|         </h4> | ||||
|         <ul *ngIf="groups.length > 0" class="list-group" formGroupName="groupsGroup"> | ||||
| 
 | ||||
|           <li class="list-group-item"> | ||||
|             <div class="row"> | ||||
|               <div class="col" i18n>Name</div> | ||||
|               <div class="col"></div> | ||||
|               <div class="col"></div> | ||||
|               <div class="col" i18n>Actions</div> | ||||
|             </div> | ||||
|           </li> | ||||
| 
 | ||||
|           <li *ngFor="let group of groups" class="list-group-item" [formGroupName]="group.id"> | ||||
|             <div class="row"> | ||||
|               <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div> | ||||
|               <div class="col"></div> | ||||
|               <div class="col"></div> | ||||
|               <div class="col"> | ||||
|                 <div class="btn-group"> | ||||
|                   <button class="btn btn-sm btn-primary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }" i18n>Edit</button> | ||||
|                   <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }" i18n>Delete</button> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </li> | ||||
|         </ul> | ||||
| 
 | ||||
|         <div *ngIf="groups.length === 0">No groups defined</div> | ||||
|       </ng-container> | ||||
| 
 | ||||
|       <div *ngIf="!users || !groups"> | ||||
|         <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||
|         <div class="visually-hidden" i18n>Loading...</div> | ||||
|       </div> | ||||
| 
 | ||||
|       </ng-template> | ||||
|     </li> | ||||
|   </ul> | ||||
| 
 | ||||
|   <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> | ||||
| @@ -0,0 +1,356 @@ | ||||
| import { ViewportScroller, DatePipe } from '@angular/common' | ||||
| import { HttpClientTestingModule } from '@angular/common/http/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 { | ||||
|   NgbModule, | ||||
|   NgbAlertModule, | ||||
|   NgbNavLink, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| import { of, throwError } from 'rxjs' | ||||
| import { routes } from 'src/app/app-routing.module' | ||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
| 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 { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| 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 { CheckComponent } from '../../common/input/check/check.component' | ||||
| import { ColorComponent } from '../../common/input/color/color.component' | ||||
| import { NumberComponent } from '../../common/input/number/number.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.component' | ||||
| import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' | ||||
|  | ||||
| const savedViews = [ | ||||
|   { id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true }, | ||||
|   { id: 2, name: 'view2', show_in_sidebar: false, show_on_dashboard: false }, | ||||
| ] | ||||
| 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('SettingsComponent', () => { | ||||
|   let component: SettingsComponent | ||||
|   let fixture: ComponentFixture<SettingsComponent> | ||||
|   let router: Router | ||||
|   let settingsService: SettingsService | ||||
|   let savedViewService: SavedViewService | ||||
|   let activatedRoute: ActivatedRoute | ||||
|   let viewportScroller: ViewportScroller | ||||
|   let toastService: ToastService | ||||
|   let userService: UserService | ||||
|   let permissionsService: PermissionsService | ||||
|   let groupService: GroupService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
|       declarations: [ | ||||
|         SettingsComponent, | ||||
|         PageHeaderComponent, | ||||
|         IfPermissionsDirective, | ||||
|         CustomDatePipe, | ||||
|         ConfirmDialogComponent, | ||||
|         CheckComponent, | ||||
|         ColorComponent, | ||||
|         SafeHtmlPipe, | ||||
|         SelectComponent, | ||||
|         TextComponent, | ||||
|         NumberComponent, | ||||
|         TagsComponent, | ||||
|         PermissionsUserComponent, | ||||
|         PermissionsGroupComponent, | ||||
|         IfOwnerDirective, | ||||
|       ], | ||||
|       providers: [CustomDatePipe, DatePipe, PermissionsGuard], | ||||
|       imports: [ | ||||
|         NgbModule, | ||||
|         HttpClientTestingModule, | ||||
|         RouterTestingModule.withRoutes(routes), | ||||
|         FormsModule, | ||||
|         ReactiveFormsModule, | ||||
|         NgbAlertModule, | ||||
|         NgSelectModule, | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     router = TestBed.inject(Router) | ||||
|     activatedRoute = TestBed.inject(ActivatedRoute) | ||||
|     viewportScroller = TestBed.inject(ViewportScroller) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     settingsService = TestBed.inject(SettingsService) | ||||
|     settingsService.currentUser = users[0] | ||||
|     userService = TestBed.inject(UserService) | ||||
|     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) | ||||
|     savedViewService = TestBed.inject(SavedViewService) | ||||
|   }) | ||||
|  | ||||
|   function completeSetup(excludeService = null) { | ||||
|     if (excludeService !== userService) { | ||||
|       jest.spyOn(userService, 'listAll').mockReturnValue( | ||||
|         of({ | ||||
|           all: users.map((u) => u.id), | ||||
|           count: users.length, | ||||
|           results: users.concat([]), | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|     if (excludeService !== groupService) { | ||||
|       jest.spyOn(groupService, 'listAll').mockReturnValue( | ||||
|         of({ | ||||
|           all: groups.map((g) => g.id), | ||||
|           count: groups.length, | ||||
|           results: groups.concat([]), | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|     if (excludeService !== savedViewService) { | ||||
|       jest.spyOn(savedViewService, 'listAll').mockReturnValue( | ||||
|         of({ | ||||
|           all: savedViews.map((v) => v.id), | ||||
|           count: savedViews.length, | ||||
|           results: (savedViews as PaperlessSavedView[]).concat([]), | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|  | ||||
|     fixture = TestBed.createComponent(SettingsComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   } | ||||
|  | ||||
|   it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => { | ||||
|     completeSetup() | ||||
|     const navigateSpy = jest.spyOn(router, 'navigate') | ||||
|     const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) | ||||
|     tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions']) | ||||
|     tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications']) | ||||
|     tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     expect(navigateSpy).toHaveBeenCalledWith(['settings', 'savedviews']) | ||||
|  | ||||
|     const initSpy = jest.spyOn(component, 'initialize') | ||||
|     component.isDirty = true // mock dirty | ||||
|     navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty | ||||
|     tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general']) | ||||
|     expect(initSpy).not.toHaveBeenCalled() | ||||
|  | ||||
|     navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty | ||||
|     tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications']) | ||||
|     expect(initSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support direct link to tab by URL, scroll if needed', () => { | ||||
|     completeSetup() | ||||
|     jest | ||||
|       .spyOn(activatedRoute, 'paramMap', 'get') | ||||
|       .mockReturnValue(of(convertToParamMap({ section: 'notifications' }))) | ||||
|     activatedRoute.snapshot.fragment = '#notifications' | ||||
|     const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') | ||||
|     component.ngOnInit() | ||||
|     expect(component.activeNavID).toEqual(3) // Users & Groups | ||||
|     component.ngAfterViewInit() | ||||
|     expect(scrollSpy).toHaveBeenCalledWith('#notifications') | ||||
|   }) | ||||
|  | ||||
|   it('should support save saved views, show error', () => { | ||||
|     completeSetup() | ||||
|  | ||||
|     const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) | ||||
|     tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     fixture.detectChanges() | ||||
|  | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastSpy = jest.spyOn(toastService, 'show') | ||||
|     const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany') | ||||
|  | ||||
|     const toggle = fixture.debugElement.query( | ||||
|       By.css('.form-check.form-switch input') | ||||
|     ) | ||||
|     toggle.nativeElement.checked = true | ||||
|     toggle.nativeElement.dispatchEvent(new Event('change')) | ||||
|  | ||||
|     // saved views error first | ||||
|     savedViewPatchSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('unable to save saved views')) | ||||
|     ) | ||||
|     component.saveSettings() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     expect(savedViewPatchSpy).toHaveBeenCalled() | ||||
|     toastSpy.mockClear() | ||||
|     toastErrorSpy.mockClear() | ||||
|     savedViewPatchSpy.mockClear() | ||||
|  | ||||
|     // succeed saved views | ||||
|     savedViewPatchSpy.mockReturnValueOnce( | ||||
|       of(savedViews as PaperlessSavedView[]) | ||||
|     ) | ||||
|     component.saveSettings() | ||||
|     expect(toastErrorSpy).not.toHaveBeenCalled() | ||||
|     expect(savedViewPatchSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should update only patch saved views that have changed', () => { | ||||
|     completeSetup() | ||||
|  | ||||
|     const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) | ||||
|     tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     fixture.detectChanges() | ||||
|  | ||||
|     const patchSpy = jest.spyOn(savedViewService, 'patchMany') | ||||
|     component.saveSettings() | ||||
|     expect(patchSpy).not.toHaveBeenCalled() | ||||
|  | ||||
|     const view = savedViews[0] | ||||
|     const toggle = fixture.debugElement.query( | ||||
|       By.css('.form-check.form-switch input') | ||||
|     ) | ||||
|     toggle.nativeElement.checked = true | ||||
|     toggle.nativeElement.dispatchEvent(new Event('change')) | ||||
|     // register change | ||||
|     component.savedViewGroup.get(view.id.toString()).value[ | ||||
|       'show_on_dashboard' | ||||
|     ] = !view.show_on_dashboard | ||||
|     fixture.detectChanges() | ||||
|  | ||||
|     component.saveSettings() | ||||
|     expect(patchSpy).toHaveBeenCalledWith([ | ||||
|       { | ||||
|         id: view.id, | ||||
|         name: view.name, | ||||
|         show_in_sidebar: view.show_in_sidebar, | ||||
|         show_on_dashboard: !view.show_on_dashboard, | ||||
|       }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should support save local settings updating appearance settings and calling API, show error', () => { | ||||
|     completeSetup() | ||||
|     jest.spyOn(savedViewService, 'patchMany').mockReturnValue(of([])) | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastSpy = jest.spyOn(toastService, 'show') | ||||
|     const storeSpy = jest.spyOn(settingsService, 'storeSettings') | ||||
|     const appearanceSettingsSpy = jest.spyOn( | ||||
|       settingsService, | ||||
|       'updateAppearanceSettings' | ||||
|     ) | ||||
|     const setSpy = jest.spyOn(settingsService, 'set') | ||||
|  | ||||
|     // error first | ||||
|     storeSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('unable to save settings')) | ||||
|     ) | ||||
|     component.saveSettings() | ||||
|  | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     expect(storeSpy).toHaveBeenCalled() | ||||
|     expect(appearanceSettingsSpy).not.toHaveBeenCalled() | ||||
|     expect(setSpy).toHaveBeenCalledTimes(24) | ||||
|  | ||||
|     // succeed | ||||
|     storeSpy.mockReturnValueOnce(of(true)) | ||||
|     component.saveSettings() | ||||
|     expect(toastSpy).toHaveBeenCalled() | ||||
|     expect(appearanceSettingsSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should offer reload if settings changes require', () => { | ||||
|     completeSetup() | ||||
|     jest.spyOn(savedViewService, 'patchMany').mockReturnValue(of([])) | ||||
|     let toast: Toast | ||||
|     toastService.getToasts().subscribe((t) => (toast = t[0])) | ||||
|     component.initialize(true) // reset | ||||
|     component.store.getValue()['displayLanguage'] = 'en-US' | ||||
|     component.store.getValue()['updateCheckingEnabled'] = false | ||||
|     component.settingsForm.value.displayLanguage = 'en-GB' | ||||
|     component.settingsForm.value.updateCheckingEnabled = true | ||||
|     jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true)) | ||||
|     component.saveSettings() | ||||
|     expect(toast.actionName).toEqual('Reload now') | ||||
|   }) | ||||
|  | ||||
|   it('should allow setting theme color, visually apply change immediately but not save', () => { | ||||
|     completeSetup() | ||||
|     const appearanceSpy = jest.spyOn( | ||||
|       settingsService, | ||||
|       'updateAppearanceSettings' | ||||
|     ) | ||||
|     const colorInput = fixture.debugElement.query(By.directive(ColorComponent)) | ||||
|     colorInput.query(By.css('input')).nativeElement.value = '#ff0000' | ||||
|     colorInput | ||||
|       .query(By.css('input')) | ||||
|       .nativeElement.dispatchEvent(new Event('change')) | ||||
|     fixture.detectChanges() | ||||
|     expect(appearanceSpy).toHaveBeenCalled() | ||||
|     expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('') | ||||
|     component.clearThemeColor() | ||||
|   }) | ||||
|  | ||||
|   it('should support delete saved view', () => { | ||||
|     completeSetup() | ||||
|     const toastSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const deleteSpy = jest.spyOn(savedViewService, 'delete') | ||||
|     deleteSpy.mockReturnValue(of(true)) | ||||
|     component.deleteSavedView(savedViews[0] as PaperlessSavedView) | ||||
|     expect(deleteSpy).toHaveBeenCalled() | ||||
|     expect(toastSpy).toHaveBeenCalledWith( | ||||
|       `Saved view "${savedViews[0].name}" deleted.` | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   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() | ||||
|   }) | ||||
| }) | ||||
							
								
								
									
										551
									
								
								src-ui/src/app/components/admin/settings/settings.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										551
									
								
								src-ui/src/app/components/admin/settings/settings.component.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,551 @@ | ||||
| import { ViewportScroller } from '@angular/common' | ||||
| import { | ||||
|   Component, | ||||
|   OnInit, | ||||
|   AfterViewInit, | ||||
|   OnDestroy, | ||||
|   Inject, | ||||
|   LOCALE_ID, | ||||
| } from '@angular/core' | ||||
| import { FormGroup, FormControl } from '@angular/forms' | ||||
| import { ActivatedRoute, Router } from '@angular/router' | ||||
| import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms' | ||||
| import { TourService } from 'ngx-ui-tour-ng-bootstrap' | ||||
| import { | ||||
|   BehaviorSubject, | ||||
|   Subscription, | ||||
|   Observable, | ||||
|   Subject, | ||||
|   first, | ||||
|   takeUntil, | ||||
|   tap, | ||||
| } from 'rxjs' | ||||
| import { PaperlessGroup } from 'src/app/data/paperless-group' | ||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
| import { PaperlessUser } from 'src/app/data/paperless-user' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { | ||||
|   PermissionsService, | ||||
|   PermissionAction, | ||||
|   PermissionType, | ||||
| } from 'src/app/services/permissions.service' | ||||
| import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { | ||||
|   SettingsService, | ||||
|   LanguageOption, | ||||
| } from 'src/app/services/settings.service' | ||||
| import { ToastService, Toast } from 'src/app/services/toast.service' | ||||
| import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | ||||
|  | ||||
| enum SettingsNavIDs { | ||||
|   General = 1, | ||||
|   Permissions = 2, | ||||
|   Notifications = 3, | ||||
|   SavedViews = 4, | ||||
| } | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-settings', | ||||
|   templateUrl: './settings.component.html', | ||||
|   styleUrls: ['./settings.component.scss'], | ||||
| }) | ||||
| export class SettingsComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, AfterViewInit, OnDestroy, DirtyComponent | ||||
| { | ||||
|   SettingsNavIDs = SettingsNavIDs | ||||
|   activeNavID: number | ||||
|  | ||||
|   savedViewGroup = new FormGroup({}) | ||||
|  | ||||
|   settingsForm = new FormGroup({ | ||||
|     bulkEditConfirmationDialogs: new FormControl(null), | ||||
|     bulkEditApplyOnClose: new FormControl(null), | ||||
|     documentListItemPerPage: new FormControl(null), | ||||
|     slimSidebarEnabled: new FormControl(null), | ||||
|     darkModeUseSystem: new FormControl(null), | ||||
|     darkModeEnabled: new FormControl(null), | ||||
|     darkModeInvertThumbs: new FormControl(null), | ||||
|     themeColor: new FormControl(null), | ||||
|     useNativePdfViewer: new FormControl(null), | ||||
|     displayLanguage: new FormControl(null), | ||||
|     dateLocale: new FormControl(null), | ||||
|     dateFormat: new FormControl(null), | ||||
|     notesEnabled: new FormControl(null), | ||||
|     updateCheckingEnabled: new FormControl(null), | ||||
|     defaultPermsOwner: new FormControl(null), | ||||
|     defaultPermsViewUsers: new FormControl(null), | ||||
|     defaultPermsViewGroups: new FormControl(null), | ||||
|     defaultPermsEditUsers: new FormControl(null), | ||||
|     defaultPermsEditGroups: new FormControl(null), | ||||
|  | ||||
|     notificationsConsumerNewDocument: new FormControl(null), | ||||
|     notificationsConsumerSuccess: new FormControl(null), | ||||
|     notificationsConsumerFailed: new FormControl(null), | ||||
|     notificationsConsumerSuppressOnDashboard: new FormControl(null), | ||||
|  | ||||
|     savedViewsWarnOnUnsavedChange: new FormControl(null), | ||||
|     savedViews: this.savedViewGroup, | ||||
|   }) | ||||
|  | ||||
|   savedViews: PaperlessSavedView[] | ||||
|  | ||||
|   store: BehaviorSubject<any> | ||||
|   storeSub: Subscription | ||||
|   isDirty$: Observable<boolean> | ||||
|   isDirty: boolean = false | ||||
|   unsubscribeNotifier: Subject<any> = new Subject() | ||||
|   savePending: boolean = false | ||||
|  | ||||
|   users: PaperlessUser[] | ||||
|   groups: PaperlessGroup[] | ||||
|  | ||||
|   get computedDateLocale(): string { | ||||
|     return ( | ||||
|       this.settingsForm.value.dateLocale || | ||||
|       this.settingsForm.value.displayLanguage || | ||||
|       this.currentLocale | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     public savedViewService: SavedViewService, | ||||
|     private documentListViewService: DocumentListViewService, | ||||
|     private toastService: ToastService, | ||||
|     private settings: SettingsService, | ||||
|     @Inject(LOCALE_ID) public currentLocale: string, | ||||
|     private viewportScroller: ViewportScroller, | ||||
|     private activatedRoute: ActivatedRoute, | ||||
|     public readonly tourService: TourService, | ||||
|     private usersService: UserService, | ||||
|     private groupsService: GroupService, | ||||
|     private router: Router, | ||||
|     public permissionsService: PermissionsService | ||||
|   ) { | ||||
|     super() | ||||
|     this.settings.settingsSaved.subscribe(() => { | ||||
|       if (!this.savePending) this.initialize() | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   ngOnInit() { | ||||
|     this.initialize() | ||||
|  | ||||
|     if ( | ||||
|       this.permissionsService.currentUserCan( | ||||
|         PermissionAction.View, | ||||
|         PermissionType.User | ||||
|       ) | ||||
|     ) { | ||||
|       this.usersService | ||||
|         .listAll() | ||||
|         .pipe(first()) | ||||
|         .subscribe({ | ||||
|           next: (r) => { | ||||
|             this.users = r.results | ||||
|           }, | ||||
|           error: (e) => { | ||||
|             this.toastService.showError($localize`Error retrieving users`, e) | ||||
|           }, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     if ( | ||||
|       this.permissionsService.currentUserCan( | ||||
|         PermissionAction.View, | ||||
|         PermissionType.Group | ||||
|       ) | ||||
|     ) { | ||||
|       this.groupsService | ||||
|         .listAll() | ||||
|         .pipe(first()) | ||||
|         .subscribe({ | ||||
|           next: (r) => { | ||||
|             this.groups = r.results | ||||
|           }, | ||||
|           error: (e) => { | ||||
|             this.toastService.showError($localize`Error retrieving groups`, e) | ||||
|           }, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     if ( | ||||
|       this.permissionsService.currentUserCan( | ||||
|         PermissionAction.View, | ||||
|         PermissionType.SavedView | ||||
|       ) | ||||
|     ) { | ||||
|       this.savedViewService.listAll().subscribe((r) => { | ||||
|         this.savedViews = r.results | ||||
|         this.initialize(false) | ||||
|       }) | ||||
|     } | ||||
|  | ||||
|     this.activatedRoute.paramMap.subscribe((paramMap) => { | ||||
|       const section = paramMap.get('section') | ||||
|       if (section) { | ||||
|         const navIDKey: string = Object.keys(SettingsNavIDs).find( | ||||
|           (navID) => navID.toLowerCase() == section | ||||
|         ) | ||||
|         if (navIDKey) { | ||||
|           this.activeNavID = SettingsNavIDs[navIDKey] | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   ngAfterViewInit(): void { | ||||
|     if (this.activatedRoute.snapshot.fragment) { | ||||
|       this.viewportScroller.scrollToAnchor( | ||||
|         this.activatedRoute.snapshot.fragment | ||||
|       ) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private getCurrentSettings() { | ||||
|     return { | ||||
|       bulkEditConfirmationDialogs: this.settings.get( | ||||
|         SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS | ||||
|       ), | ||||
|       bulkEditApplyOnClose: this.settings.get( | ||||
|         SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE | ||||
|       ), | ||||
|       documentListItemPerPage: this.settings.get( | ||||
|         SETTINGS_KEYS.DOCUMENT_LIST_SIZE | ||||
|       ), | ||||
|       slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR), | ||||
|       darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM), | ||||
|       darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED), | ||||
|       darkModeInvertThumbs: this.settings.get( | ||||
|         SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED | ||||
|       ), | ||||
|       themeColor: this.settings.get(SETTINGS_KEYS.THEME_COLOR), | ||||
|       useNativePdfViewer: this.settings.get( | ||||
|         SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER | ||||
|       ), | ||||
|       displayLanguage: this.settings.getLanguage(), | ||||
|       dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE), | ||||
|       dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT), | ||||
|       notesEnabled: this.settings.get(SETTINGS_KEYS.NOTES_ENABLED), | ||||
|       updateCheckingEnabled: this.settings.get( | ||||
|         SETTINGS_KEYS.UPDATE_CHECKING_ENABLED | ||||
|       ), | ||||
|       notificationsConsumerNewDocument: this.settings.get( | ||||
|         SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT | ||||
|       ), | ||||
|       notificationsConsumerSuccess: this.settings.get( | ||||
|         SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS | ||||
|       ), | ||||
|       notificationsConsumerFailed: this.settings.get( | ||||
|         SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED | ||||
|       ), | ||||
|       notificationsConsumerSuppressOnDashboard: this.settings.get( | ||||
|         SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD | ||||
|       ), | ||||
|       savedViewsWarnOnUnsavedChange: this.settings.get( | ||||
|         SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE | ||||
|       ), | ||||
|       defaultPermsOwner: this.settings.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER), | ||||
|       defaultPermsViewUsers: this.settings.get( | ||||
|         SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS | ||||
|       ), | ||||
|       defaultPermsViewGroups: this.settings.get( | ||||
|         SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS | ||||
|       ), | ||||
|       defaultPermsEditUsers: this.settings.get( | ||||
|         SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS | ||||
|       ), | ||||
|       defaultPermsEditGroups: this.settings.get( | ||||
|         SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS | ||||
|       ), | ||||
|       savedViews: {}, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onNavChange(navChangeEvent: NgbNavChangeEvent) { | ||||
|     const [foundNavIDkey] = Object.entries(SettingsNavIDs).find( | ||||
|       ([, navIDValue]) => navIDValue == navChangeEvent.nextId | ||||
|     ) | ||||
|     if (foundNavIDkey) | ||||
|       // if its dirty we need to wait for confirmation | ||||
|       this.router | ||||
|         .navigate(['settings', foundNavIDkey.toLowerCase()]) | ||||
|         .then((navigated) => { | ||||
|           if (!navigated && this.isDirty) { | ||||
|             this.activeNavID = navChangeEvent.activeId | ||||
|           } else if (navigated && this.isDirty) { | ||||
|             this.initialize() | ||||
|           } | ||||
|         }) | ||||
|   } | ||||
|  | ||||
|   initialize(resetSettings: boolean = true) { | ||||
|     this.unsubscribeNotifier.next(true) | ||||
|  | ||||
|     const currentFormValue = this.settingsForm.value | ||||
|  | ||||
|     let storeData = this.getCurrentSettings() | ||||
|  | ||||
|     if (this.savedViews) { | ||||
|       this.emptyGroup(this.savedViewGroup) | ||||
|  | ||||
|       for (let view of this.savedViews) { | ||||
|         storeData.savedViews[view.id.toString()] = { | ||||
|           id: view.id, | ||||
|           name: view.name, | ||||
|           show_on_dashboard: view.show_on_dashboard, | ||||
|           show_in_sidebar: view.show_in_sidebar, | ||||
|         } | ||||
|         this.savedViewGroup.addControl( | ||||
|           view.id.toString(), | ||||
|           new FormGroup({ | ||||
|             id: new FormControl(null), | ||||
|             name: new FormControl(null), | ||||
|             show_on_dashboard: new FormControl(null), | ||||
|             show_in_sidebar: new FormControl(null), | ||||
|           }) | ||||
|         ) | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     this.store = new BehaviorSubject(storeData) | ||||
|  | ||||
|     this.storeSub = this.store.asObservable().subscribe((state) => { | ||||
|       this.settingsForm.patchValue(state, { emitEvent: false }) | ||||
|     }) | ||||
|  | ||||
|     // Initialize dirtyCheck | ||||
|     this.isDirty$ = dirtyCheck(this.settingsForm, this.store.asObservable()) | ||||
|  | ||||
|     // Record dirty in case we need to 'undo' appearance settings if not saved on close | ||||
|     this.isDirty$ | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe((dirty) => { | ||||
|         this.isDirty = dirty | ||||
|       }) | ||||
|  | ||||
|     // "Live" visual changes prior to save | ||||
|     this.settingsForm.valueChanges | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         this.settings.updateAppearanceSettings( | ||||
|           this.settingsForm.get('darkModeUseSystem').value, | ||||
|           this.settingsForm.get('darkModeEnabled').value, | ||||
|           this.settingsForm.get('themeColor').value | ||||
|         ) | ||||
|       }) | ||||
|  | ||||
|     if (!resetSettings && currentFormValue) { | ||||
|       // prevents loss of unsaved changes | ||||
|       this.settingsForm.patchValue(currentFormValue) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private emptyGroup(group: FormGroup) { | ||||
|     Object.keys(group.controls).forEach((key) => group.removeControl(key)) | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
|     if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didnt save | ||||
|     this.storeSub && this.storeSub.unsubscribe() | ||||
|   } | ||||
|  | ||||
|   deleteSavedView(savedView: PaperlessSavedView) { | ||||
|     this.savedViewService.delete(savedView).subscribe(() => { | ||||
|       this.savedViewGroup.removeControl(savedView.id.toString()) | ||||
|       this.savedViews.splice(this.savedViews.indexOf(savedView), 1) | ||||
|       this.toastService.showInfo( | ||||
|         $localize`Saved view "${savedView.name}" deleted.` | ||||
|       ) | ||||
|       this.savedViewService.clearCache() | ||||
|       this.savedViewService.listAll().subscribe((r) => { | ||||
|         this.savedViews = r.results | ||||
|         this.initialize(true) | ||||
|       }) | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   private saveLocalSettings() { | ||||
|     this.savePending = true | ||||
|     const reloadRequired = | ||||
|       this.settingsForm.value.displayLanguage != | ||||
|         this.store?.getValue()['displayLanguage'] || // displayLanguage is dirty | ||||
|       (this.settingsForm.value.updateCheckingEnabled != | ||||
|         this.store?.getValue()['updateCheckingEnabled'] && | ||||
|         this.settingsForm.value.updateCheckingEnabled) // update checking was turned on | ||||
|  | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE, | ||||
|       this.settingsForm.value.bulkEditApplyOnClose | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS, | ||||
|       this.settingsForm.value.bulkEditConfirmationDialogs | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DOCUMENT_LIST_SIZE, | ||||
|       this.settingsForm.value.documentListItemPerPage | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.SLIM_SIDEBAR, | ||||
|       this.settingsForm.value.slimSidebarEnabled | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DARK_MODE_USE_SYSTEM, | ||||
|       this.settingsForm.value.darkModeUseSystem | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DARK_MODE_ENABLED, | ||||
|       (this.settingsForm.value.darkModeEnabled == true).toString() | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED, | ||||
|       (this.settingsForm.value.darkModeInvertThumbs == true).toString() | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.THEME_COLOR, | ||||
|       this.settingsForm.value.themeColor.toString() | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, | ||||
|       this.settingsForm.value.useNativePdfViewer | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DATE_LOCALE, | ||||
|       this.settingsForm.value.dateLocale | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DATE_FORMAT, | ||||
|       this.settingsForm.value.dateFormat | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT, | ||||
|       this.settingsForm.value.notificationsConsumerNewDocument | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, | ||||
|       this.settingsForm.value.notificationsConsumerSuccess | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED, | ||||
|       this.settingsForm.value.notificationsConsumerFailed | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD, | ||||
|       this.settingsForm.value.notificationsConsumerSuppressOnDashboard | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.NOTES_ENABLED, | ||||
|       this.settingsForm.value.notesEnabled | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, | ||||
|       this.settingsForm.value.updateCheckingEnabled | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE, | ||||
|       this.settingsForm.value.savedViewsWarnOnUnsavedChange | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DEFAULT_PERMS_OWNER, | ||||
|       this.settingsForm.value.defaultPermsOwner | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS, | ||||
|       this.settingsForm.value.defaultPermsViewUsers | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS, | ||||
|       this.settingsForm.value.defaultPermsViewGroups | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS, | ||||
|       this.settingsForm.value.defaultPermsEditUsers | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS, | ||||
|       this.settingsForm.value.defaultPermsEditGroups | ||||
|     ) | ||||
|     this.settings.setLanguage(this.settingsForm.value.displayLanguage) | ||||
|     this.settings | ||||
|       .storeSettings() | ||||
|       .pipe(first()) | ||||
|       .pipe(tap(() => (this.savePending = false))) | ||||
|       .subscribe({ | ||||
|         next: () => { | ||||
|           this.store.next(this.settingsForm.value) | ||||
|           this.documentListViewService.updatePageSize() | ||||
|           this.settings.updateAppearanceSettings() | ||||
|           let savedToast: Toast = { | ||||
|             title: $localize`Settings saved`, | ||||
|             content: $localize`Settings were saved successfully.`, | ||||
|             delay: 5000, | ||||
|           } | ||||
|           if (reloadRequired) { | ||||
|             savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.` | ||||
|             savedToast.actionName = $localize`Reload now` | ||||
|             savedToast.action = () => { | ||||
|               location.reload() | ||||
|             } | ||||
|           } | ||||
|  | ||||
|           this.toastService.show(savedToast) | ||||
|         }, | ||||
|         error: (error) => { | ||||
|           this.toastService.showError( | ||||
|             $localize`An error occurred while saving settings.`, | ||||
|             error | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   get displayLanguageOptions(): LanguageOption[] { | ||||
|     return [{ code: '', name: $localize`Use system language` }].concat( | ||||
|       this.settings.getLanguageOptions() | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   get dateLocaleOptions(): LanguageOption[] { | ||||
|     return [ | ||||
|       { code: '', name: $localize`Use date format of display language` }, | ||||
|     ].concat(this.settings.getDateLocaleOptions()) | ||||
|   } | ||||
|  | ||||
|   get today() { | ||||
|     return new Date() | ||||
|   } | ||||
|  | ||||
|   saveSettings() { | ||||
|     // only patch views that have actually changed | ||||
|     const changed: PaperlessSavedView[] = [] | ||||
|     Object.values(this.savedViewGroup.controls) | ||||
|       .filter((g: FormGroup) => !g.pristine) | ||||
|       .forEach((group: FormGroup) => { | ||||
|         changed.push(group.value) | ||||
|       }) | ||||
|     if (changed.length > 0) { | ||||
|       this.savedViewService.patchMany(changed).subscribe({ | ||||
|         next: () => { | ||||
|           this.saveLocalSettings() | ||||
|         }, | ||||
|         error: (error) => { | ||||
|           this.toastService.showError( | ||||
|             $localize`Error while storing settings on server.`, | ||||
|             error | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|     } else { | ||||
|       this.saveLocalSettings() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   clearThemeColor() { | ||||
|     this.settingsForm.get('themeColor').patchValue('') | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,99 @@ | ||||
| <pngx-page-header title="Users & Groups" i18n-title> | ||||
| </pngx-page-header> | ||||
|  | ||||
| <ng-container *ngIf="users"> | ||||
|     <h4 class="d-flex"> | ||||
|         <ng-container i18n>Users</ng-container> | ||||
|         <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }"> | ||||
|         <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|         </svg> | ||||
|         <ng-container i18n>Add User</ng-container> | ||||
|         </button> | ||||
|     </h4> | ||||
|     <ul class="list-group"> | ||||
|  | ||||
|         <li class="list-group-item"> | ||||
|         <div class="row"> | ||||
|             <div class="col" i18n>Username</div> | ||||
|             <div class="col" i18n>Name</div> | ||||
|             <div class="col" i18n>Groups</div> | ||||
|             <div class="col" i18n>Actions</div> | ||||
|         </div> | ||||
|         </li> | ||||
|  | ||||
|         <li *ngFor="let user of users" class="list-group-item"> | ||||
|         <div class="row"> | ||||
|             <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div> | ||||
|             <div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div> | ||||
|             <div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div> | ||||
|             <div class="col"> | ||||
|             <div class="btn-group"> | ||||
|                 <button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#pencil" /> | ||||
|                     </svg> <ng-container i18n>Edit</ng-container> | ||||
|                 </button> | ||||
|                 <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|                     </svg> <ng-container i18n>Delete</ng-container> | ||||
|                 </button> | ||||
|             </div> | ||||
|             </div> | ||||
|         </div> | ||||
|         </li> | ||||
|     </ul> | ||||
| </ng-container> | ||||
|  | ||||
| <ng-container *ngIf="groups"> | ||||
|     <h4 class="mt-4 d-flex"> | ||||
|         <ng-container i18n>Groups</ng-container> | ||||
|         <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }"> | ||||
|         <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|         </svg> | ||||
|         <ng-container i18n>Add Group</ng-container> | ||||
|         </button> | ||||
|     </h4> | ||||
|     <ul *ngIf="groups.length > 0" class="list-group"> | ||||
|  | ||||
|         <li class="list-group-item"> | ||||
|         <div class="row"> | ||||
|             <div class="col" i18n>Name</div> | ||||
|             <div class="col"></div> | ||||
|             <div class="col"></div> | ||||
|             <div class="col" i18n>Actions</div> | ||||
|         </div> | ||||
|         </li> | ||||
|  | ||||
|         <li *ngFor="let group of groups" class="list-group-item"> | ||||
|         <div class="row"> | ||||
|             <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div> | ||||
|             <div class="col"></div> | ||||
|             <div class="col"></div> | ||||
|             <div class="col"> | ||||
|             <div class="btn-group"> | ||||
|                 <button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#pencil" /> | ||||
|                     </svg> <ng-container i18n>Edit</ng-container> | ||||
|                 </button> | ||||
|                 <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|                     </svg> <ng-container i18n>Delete</ng-container> | ||||
|                 </button> | ||||
|             </div> | ||||
|             </div> | ||||
|         </div> | ||||
|         </li> | ||||
|     </ul> | ||||
|  | ||||
|     <div *ngIf="groups.length === 0">No groups defined</div> | ||||
| </ng-container> | ||||
|  | ||||
| <div *ngIf="!users || !groups"> | ||||
|     <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||
|     <div class="visually-hidden" i18n>Loading...</div> | ||||
| </div> | ||||
| @@ -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<UsersAndGroupsComponent> | ||||
|   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() | ||||
|   }) | ||||
| }) | ||||
| @@ -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<any> = 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 ?? '' | ||||
|   } | ||||
| } | ||||
| @@ -48,6 +48,12 @@ | ||||
|           <svg class="sidebaricon me-2" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#door-open"/> | ||||
|           </svg><ng-container i18n>Logout</ng-container> | ||||
|         </a> | ||||
| 				<div class="dropdown-divider"></div> | ||||
|         <a ngbDropdownItem class="nav-link" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com"> | ||||
|           <svg class="sidebaricon me-2" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#question-circle"/> | ||||
|           </svg><ng-container i18n>Documentation</ng-container> | ||||
|         </a> | ||||
|       </div> | ||||
|     </li> | ||||
| @@ -80,8 +86,8 @@ | ||||
|             </a> | ||||
|           </li> | ||||
|         </ul> | ||||
|         <div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> | ||||
|           <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.loading || savedViewService.sidebarViews.length > 0'> | ||||
|         <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> | ||||
|           <h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted" *ngIf='savedViewService.loading || savedViewService.sidebarViews.length > 0'> | ||||
|             <span i18n>Saved views</span> | ||||
|             <div *ngIf="savedViewService.loading" class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div> | ||||
|           </h6> | ||||
| @@ -94,10 +100,10 @@ | ||||
|               </a> | ||||
|             </li> | ||||
|           </ul> | ||||
|         </div> | ||||
|         </ng-container> | ||||
|  | ||||
|         <div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> | ||||
|           <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'> | ||||
|         <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> | ||||
|           <h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted" *ngIf='openDocuments.length > 0'> | ||||
|             <span i18n>Open documents</span> | ||||
|           </h6> | ||||
|           <ul class="nav flex-column mb-2"> | ||||
| @@ -121,9 +127,9 @@ | ||||
|               </a> | ||||
|             </li> | ||||
|           </ul> | ||||
|       </div> | ||||
|         </ng-container> | ||||
|  | ||||
|         <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted"> | ||||
|         <h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted"> | ||||
|           <span i18n>Manage</span> | ||||
|         </h6> | ||||
|         <ul class="nav flex-column mb-2"> | ||||
| @@ -155,13 +161,40 @@ | ||||
|               </svg><span> <ng-container i18n>Storage paths</ng-container></span> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }"> | ||||
|           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }" tourAnchor="tour.consumption-templates"> | ||||
|             <a class="nav-link" routerLink="templates" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Consumption templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#file-earmark-ruled"/> | ||||
|               </svg><span> <ng-container i18n>Templates</ng-container></span> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" tourAnchor="tour.mail"> | ||||
|             <a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#envelope"/> | ||||
|               </svg><span> <ng-container i18n>Mail</ng-container></span> | ||||
|             </a> | ||||
|           </li> | ||||
|         </ul> | ||||
|  | ||||
|         <h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted"> | ||||
|           <span i18n>Administration</span> | ||||
|         </h6> | ||||
|         <ul class="nav flex-column mb-2"> | ||||
|           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }" tourAnchor="tour.settings"> | ||||
|             <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#gear"/> | ||||
|               </svg><span> <ng-container i18n>Settings</ng-container></span> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }"> | ||||
|             <a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#people"/> | ||||
|               </svg><span> <ng-container i18n>Users & Groups</ng-container></span> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" tourAnchor="tour.file-tasks"> | ||||
|             <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|               <span *ngIf="tasksService.failedFileTasks.length > 0 && slimSidebarEnabled" class="badge bg-danger position-absolute top-0 end-0">{{tasksService.failedFileTasks.length}}</span> | ||||
| @@ -177,44 +210,20 @@ | ||||
|               </svg><span> <ng-container i18n>Logs</ng-container></span> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }" tourAnchor="tour.settings"> | ||||
|             <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#gear"/> | ||||
|               </svg><span> <ng-container i18n>Settings</ng-container></span> | ||||
|             </a> | ||||
|           </li> | ||||
|         </ul> | ||||
|  | ||||
|         <h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted"> | ||||
|           <span i18n>Info</span> | ||||
|         </h6> | ||||
|         <ul class="nav flex-column mb-2"> | ||||
|           <li class="nav-item" tourAnchor="tour.outro"> | ||||
|             <a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|           <li class="nav-item mt-2" tourAnchor="tour.outro"> | ||||
|             <a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#question-circle"/> | ||||
|               </svg><span> <ng-container i18n>Documentation</ng-container></span> | ||||
|               </svg><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item"> | ||||
|             <div class="d-flex w-100 flex-wrap"> | ||||
|               <a class="nav-link pe-2 pb-1" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="sidebaricon" viewBox="0 0 16 16"> | ||||
|                   <use xlink:href="assets/bootstrap-icons.svg#github" /> | ||||
|                 </svg><span> <ng-container i18n>GitHub</ng-container></span> | ||||
|               </a> | ||||
|               <a class="nav-link-additional small text-muted ms-3" [class.visually-hidden]="slimSidebarEnabled" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx/discussions/categories/feature-requests" title="Suggest an idea" i18n-title> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" width="1.1em" height="1.1em" fill="currentColor" class="me-1" viewBox="0 0 16 16"> | ||||
|                   <use xlink:href="assets/bootstrap-icons.svg#lightbulb" /> | ||||
|                 </svg> | ||||
|                 <ng-container i18n>Suggest an idea</ng-container> | ||||
|               </a> | ||||
|             </div> | ||||
|           </li> | ||||
|           <li class="nav-item mt-2" [class.visually-hidden]="slimSidebarEnabled"> | ||||
|             <div class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap"> | ||||
|               <div class="me-3">{{ versionString }}</div> | ||||
|           <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> | ||||
|             <div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap"> | ||||
|               <div class="me-3"> | ||||
|                 <a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                   {{ versionString }} | ||||
|                 </a> | ||||
|               </div> | ||||
|               <div *ngIf="!settingsService.updateCheckingIsSet || appRemoteVersion" class="version-check"> | ||||
|                 <ng-template #updateAvailablePopContent> | ||||
|                   <span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is available.</ng-container><br/><ng-container i18n>Click to view.</ng-container></span> | ||||
|   | ||||
| @@ -1,33 +0,0 @@ | ||||
| <pngx-page-header title="Consumption Templates"> | ||||
|     <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ConsumptionTemplate }"> | ||||
|       <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|       </svg> | ||||
|       <ng-container i18n>Add Template</ng-container> | ||||
|     </button> | ||||
|   </pngx-page-header> | ||||
|  | ||||
|   <table class="table table-striped align-middle border shadow-sm"> | ||||
|     <thead> | ||||
|         <tr> | ||||
|             <th scope="col" i18n>Name</th> | ||||
|             <th scope="col" i18n>Sort order</th> | ||||
|             <th scope="col" i18n>Document Sources</th> | ||||
|             <th scope="col" i18n>Actions</th> | ||||
|         </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|         <tr *ngFor="let template of templates"> | ||||
|             <td scope="row"><button class="btn btn-link p-0" type="button" (click)="editTemplate(template)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.ConsumptionTemplate)">{{template.name}}</button></td> | ||||
|             <td scope="row"><code>{{template.order}}</code></td> | ||||
|             <td scope="row">{{getSourceList(template)}}</td> | ||||
|             <td scope="row"> | ||||
|                 <div class="btn-group"> | ||||
|                     <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-primary" type="button" (click)="editTemplate(template)" i18n>Edit</button> | ||||
|                     <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)" i18n>Delete</button> | ||||
|                 </div> | ||||
|             </td> | ||||
|         </tr> | ||||
|     </tbody> | ||||
| </table> | ||||
| <div *ngIf="templates.length === 0" i18n>No templates defined.</div> | ||||
| @@ -0,0 +1,44 @@ | ||||
| <pngx-page-header title="Consumption Templates"> | ||||
|     <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ConsumptionTemplate }"> | ||||
|       <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|       </svg> | ||||
|       <ng-container i18n>Add Template</ng-container> | ||||
|     </button> | ||||
| </pngx-page-header> | ||||
|  | ||||
| <ul class="list-group"> | ||||
|  | ||||
|     <li class="list-group-item"> | ||||
|         <div class="row"> | ||||
|             <div class="col" i18n>Name</div> | ||||
|             <div class="col" i18n>Sort order</div> | ||||
|             <div class="col" i18n>Document Sources</div> | ||||
|             <div class="col" i18n>Actions</div> | ||||
|         </div> | ||||
|     </li> | ||||
|  | ||||
|     <li *ngFor="let template of templates" class="list-group-item"> | ||||
|         <div class="row"> | ||||
|             <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editTemplate(template)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.ConsumptionTemplate)">{{template.name}}</button></div> | ||||
|             <div class="col d-flex align-items-center"><code>{{template.order}}</code></div> | ||||
|             <div class="col d-flex align-items-center">{{getSourceList(template)}}</div> | ||||
|             <div class="col"> | ||||
|                 <div class="btn-group"> | ||||
|                     <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(template)"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#pencil" /> | ||||
|                       </svg> <ng-container i18n>Edit</ng-container> | ||||
|                     </button> | ||||
|                     <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)"> | ||||
|                         <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                             <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|                         </svg> <ng-container i18n>Delete</ng-container> | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </li> | ||||
| </ul> | ||||
|  | ||||
| <div *ngIf="templates.length === 0" i18n>No templates defined.</div> | ||||
| @@ -18,7 +18,7 @@ import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-te | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { PageHeaderComponent } from '../../common/page-header/page-header.component' | ||||
| import { ConsumptionTemplatesListComponent } from './consumption-templates-list.component' | ||||
| import { ConsumptionTemplatesComponent } from './consumption-templates.component' | ||||
| import { ConsumptionTemplateEditDialogComponent } from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| 
 | ||||
| @@ -48,8 +48,8 @@ const templates: PaperlessConsumptionTemplate[] = [ | ||||
| ] | ||||
| 
 | ||||
| describe('ConsumptionTemplatesComponent', () => { | ||||
|   let component: ConsumptionTemplatesListComponent | ||||
|   let fixture: ComponentFixture<ConsumptionTemplatesListComponent> | ||||
|   let component: ConsumptionTemplatesComponent | ||||
|   let fixture: ComponentFixture<ConsumptionTemplatesComponent> | ||||
|   let consumptionTemplateService: ConsumptionTemplateService | ||||
|   let modalService: NgbModal | ||||
|   let toastService: ToastService | ||||
| @@ -57,7 +57,7 @@ describe('ConsumptionTemplatesComponent', () => { | ||||
|   beforeEach(() => { | ||||
|     TestBed.configureTestingModule({ | ||||
|       declarations: [ | ||||
|         ConsumptionTemplatesListComponent, | ||||
|         ConsumptionTemplatesComponent, | ||||
|         IfPermissionsDirective, | ||||
|         PageHeaderComponent, | ||||
|         ConfirmDialogComponent, | ||||
| @@ -92,7 +92,7 @@ describe('ConsumptionTemplatesComponent', () => { | ||||
|     modalService = TestBed.inject(NgbModal) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
| 
 | ||||
|     fixture = TestBed.createComponent(ConsumptionTemplatesListComponent) | ||||
|     fixture = TestBed.createComponent(ConsumptionTemplatesComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
| @@ -14,11 +14,11 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial | ||||
| import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'pngx-consumption-templates-list', | ||||
|   templateUrl: './consumption-templates-list.component.html', | ||||
|   styleUrls: ['./consumption-templates-list.component.scss'], | ||||
|   selector: 'pngx-consumption-templates', | ||||
|   templateUrl: './consumption-templates.component.html', | ||||
|   styleUrls: ['./consumption-templates.component.scss'], | ||||
| }) | ||||
| export class ConsumptionTemplatesListComponent | ||||
| export class ConsumptionTemplatesComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit | ||||
| { | ||||
							
								
								
									
										107
									
								
								src-ui/src/app/components/manage/mail/mail.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src-ui/src/app/components/manage/mail/mail.component.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | ||||
| <pngx-page-header title="Mail Settings" i18n-title> | ||||
| </pngx-page-header> | ||||
|  | ||||
| <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"> | ||||
|     <h4> | ||||
|         <ng-container i18n>Mail accounts</ng-container> | ||||
|         <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailAccount()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }"> | ||||
|         <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|         </svg> | ||||
|         <ng-container i18n>Add Account</ng-container> | ||||
|         </button> | ||||
|     </h4> | ||||
|     <ul class="list-group"> | ||||
|  | ||||
|         <li class="list-group-item"> | ||||
|             <div class="row"> | ||||
|             <div class="col" i18n>Name</div> | ||||
|             <div class="col" i18n>Server</div> | ||||
|             <div class="col" i18n>Actions</div> | ||||
|             </div> | ||||
|         </li> | ||||
|  | ||||
|         <li *ngFor="let account of mailAccounts" class="list-group-item"> | ||||
|             <div class="row"> | ||||
|             <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div> | ||||
|             <div class="col d-flex align-items-center">{{account.imap_server}}</div> | ||||
|             <div class="col"> | ||||
|                 <div class="btn-group"> | ||||
|                 <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" [disabled]="!userCanEdit(account)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailAccount(account)"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#pencil" /> | ||||
|                     </svg> <ng-container i18n>Edit</ng-container> | ||||
|                 </button> | ||||
|                 <button *pngxIfOwner="account" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(account)"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#person-lock" /> | ||||
|                     </svg> <ng-container i18n>Permissions</ng-container> | ||||
|                 </button> | ||||
|                 <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailAccount }" [disabled]="!userIsOwner(account)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|                     </svg> <ng-container i18n>Delete</ng-container> | ||||
|                 </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|             </div> | ||||
|         </li> | ||||
|  | ||||
|         <div *ngIf="mailAccounts.length === 0" i18n>No mail accounts defined.</div> | ||||
|     </ul> | ||||
|     </ng-container> | ||||
|  | ||||
|     <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }"> | ||||
|     <h4 class="mt-4"> | ||||
|         <ng-container i18n>Mail rules</ng-container> | ||||
|         <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailRule()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }"> | ||||
|         <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|         </svg> | ||||
|         <ng-container i18n>Add Rule</ng-container> | ||||
|         </button> | ||||
|     </h4> | ||||
|     <ul class="list-group"> | ||||
|  | ||||
|         <li class="list-group-item"> | ||||
|             <div class="row"> | ||||
|             <div class="col" i18n>Name</div> | ||||
|             <div class="col" i18n>Account</div> | ||||
|             <div class="col" i18n>Actions</div> | ||||
|             </div> | ||||
|         </li> | ||||
|  | ||||
|         <li *ngFor="let rule of mailRules" class="list-group-item"> | ||||
|             <div class="row"> | ||||
|             <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div> | ||||
|             <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> | ||||
|             <div class="col"> | ||||
|                 <div class="btn-group"> | ||||
|                 <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" [disabled]="!userCanEdit(rule)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailRule(rule)"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#pencil" /> | ||||
|                     </svg> <ng-container i18n>Edit</ng-container> | ||||
|                 </button> | ||||
|                 <button *pngxIfOwner="rule" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(rule)"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#person-lock" /> | ||||
|                     </svg> <ng-container i18n>Permissions</ng-container> | ||||
|                 </button> | ||||
|                 <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailRule }" [disabled]="!userIsOwner(rule)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)"> | ||||
|                     <svg class="buttonicon-sm" fill="currentColor"> | ||||
|                         <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|                     </svg> <ng-container i18n>Delete</ng-container> | ||||
|                 </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|             </div> | ||||
|         </li> | ||||
|  | ||||
|         <div *ngIf="mailRules.length === 0" i18n>No mail rules defined.</div> | ||||
|     </ul> | ||||
| </ng-container> | ||||
|  | ||||
| <div *ngIf="!mailAccounts || !mailRules"> | ||||
|     <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||
|     <div class="visually-hidden" i18n>Loading...</div> | ||||
| </div> | ||||
							
								
								
									
										304
									
								
								src-ui/src/app/components/manage/mail/mail.component.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										304
									
								
								src-ui/src/app/components/manage/mail/mail.component.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,304 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
|  | ||||
| import { MailComponent } from './mail.component' | ||||
| import { DatePipe } from '@angular/common' | ||||
| import { HttpClientTestingModule } from '@angular/common/http/testing' | ||||
| import { RouterTestingModule } from '@angular/router/testing' | ||||
| import { | ||||
|   NgbModule, | ||||
|   NgbAlertModule, | ||||
|   NgbModalRef, | ||||
|   NgbModal, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| import { of, throwError } from 'rxjs' | ||||
| import { routes } from 'src/app/app-routing.module' | ||||
| import { PaperlessMailAccount } from 'src/app/data/paperless-mail-account' | ||||
| import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule' | ||||
| 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 { MailAccountService } from 'src/app/services/rest/mail-account.service' | ||||
| import { MailRuleService } from 'src/app/services/rest/mail-rule.service' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component' | ||||
| import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-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 { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.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 { TextComponent } from '../../common/input/text/text.component' | ||||
| import { PageHeaderComponent } from '../../common/page-header/page-header.component' | ||||
| import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { TagsComponent } from '../../common/input/tags/tags.component' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' | ||||
|  | ||||
| const mailAccounts = [ | ||||
|   { id: 1, name: 'account1' }, | ||||
|   { id: 2, name: 'account2' }, | ||||
| ] | ||||
| const mailRules = [ | ||||
|   { id: 1, name: 'rule1', owner: 1, account: 1 }, | ||||
|   { id: 2, name: 'rule2', owner: 2, account: 2 }, | ||||
| ] | ||||
|  | ||||
| describe('MailComponent', () => { | ||||
|   let component: MailComponent | ||||
|   let fixture: ComponentFixture<MailComponent> | ||||
|   let mailAccountService: MailAccountService | ||||
|   let mailRuleService: MailRuleService | ||||
|   let modalService: NgbModal | ||||
|   let toastService: ToastService | ||||
|   let permissionsService: PermissionsService | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     TestBed.configureTestingModule({ | ||||
|       declarations: [ | ||||
|         MailComponent, | ||||
|         PageHeaderComponent, | ||||
|         IfPermissionsDirective, | ||||
|         CustomDatePipe, | ||||
|         ConfirmDialogComponent, | ||||
|         CheckComponent, | ||||
|         SafeHtmlPipe, | ||||
|         SelectComponent, | ||||
|         TextComponent, | ||||
|         PasswordComponent, | ||||
|         NumberComponent, | ||||
|         MailAccountEditDialogComponent, | ||||
|         MailRuleEditDialogComponent, | ||||
|         IfOwnerDirective, | ||||
|         TagsComponent, | ||||
|         PermissionsUserComponent, | ||||
|         PermissionsGroupComponent, | ||||
|         PermissionsDialogComponent, | ||||
|         PermissionsFormComponent, | ||||
|       ], | ||||
|       providers: [CustomDatePipe, DatePipe, PermissionsGuard], | ||||
|       imports: [ | ||||
|         NgbModule, | ||||
|         HttpClientTestingModule, | ||||
|         RouterTestingModule.withRoutes(routes), | ||||
|         FormsModule, | ||||
|         ReactiveFormsModule, | ||||
|         NgbAlertModule, | ||||
|         NgSelectModule, | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     mailAccountService = TestBed.inject(MailAccountService) | ||||
|     mailRuleService = TestBed.inject(MailRuleService) | ||||
|     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) | ||||
|  | ||||
|     fixture = TestBed.createComponent(MailComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   function completeSetup(excludeService = null) { | ||||
|     if (excludeService !== mailAccountService) { | ||||
|       jest.spyOn(mailAccountService, 'listAll').mockReturnValue( | ||||
|         of({ | ||||
|           all: mailAccounts.map((a) => a.id), | ||||
|           count: mailAccounts.length, | ||||
|           results: (mailAccounts as PaperlessMailAccount[]).concat([]), | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|     if (excludeService !== mailRuleService) { | ||||
|       jest.spyOn(mailRuleService, 'listAll').mockReturnValue( | ||||
|         of({ | ||||
|           all: mailRules.map((r) => r.id), | ||||
|           count: mailRules.length, | ||||
|           results: (mailRules as PaperlessMailRule[]).concat([]), | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|  | ||||
|     fixture = TestBed.createComponent(MailComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   } | ||||
|  | ||||
|   it('should show errors on load if load mailAccounts failure', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     jest | ||||
|       .spyOn(mailAccountService, 'listAll') | ||||
|       .mockImplementation(() => | ||||
|         throwError(() => new Error('failed to load mail accounts')) | ||||
|       ) | ||||
|     completeSetup(mailAccountService) | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should show errors on load if load mailRules failure', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     jest | ||||
|       .spyOn(mailRuleService, 'listAll') | ||||
|       .mockImplementation(() => | ||||
|         throwError(() => new Error('failed to load mail rules')) | ||||
|       ) | ||||
|     completeSetup(mailRuleService) | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support edit / create mail account, show error if needed', () => { | ||||
|     completeSetup() | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.editMailAccount(mailAccounts[0] as PaperlessMailAccount) | ||||
|     let editDialog = modal.componentInstance as MailAccountEditDialogComponent | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     editDialog.succeeded.emit(mailAccounts[0]) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
|       `Saved account "${mailAccounts[0].name}".` | ||||
|     ) | ||||
|     editDialog.cancel() | ||||
|     component.editMailAccount() | ||||
|   }) | ||||
|  | ||||
|   it('should support delete mail account, show error if needed', () => { | ||||
|     completeSetup() | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount) | ||||
|     const deleteDialog = modal.componentInstance as ConfirmDialogComponent | ||||
|     const deleteSpy = jest.spyOn(mailAccountService, 'delete') | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const listAllSpy = jest.spyOn(mailAccountService, 'listAll') | ||||
|     deleteSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('error deleting mail account')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail account') | ||||
|   }) | ||||
|  | ||||
|   it('should support edit / create mail rule, show error if needed', () => { | ||||
|     completeSetup() | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.editMailRule(mailRules[0] as PaperlessMailRule) | ||||
|     const editDialog = modal.componentInstance as MailRuleEditDialogComponent | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     editDialog.succeeded.emit(mailRules[0]) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
|       `Saved rule "${mailRules[0].name}".` | ||||
|     ) | ||||
|     editDialog.cancel() | ||||
|     component.editMailRule() | ||||
|   }) | ||||
|  | ||||
|   it('should support delete mail rule, show error if needed', () => { | ||||
|     completeSetup() | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.deleteMailRule(mailRules[0] as PaperlessMailRule) | ||||
|     const deleteDialog = modal.componentInstance as ConfirmDialogComponent | ||||
|     const deleteSpy = jest.spyOn(mailRuleService, 'delete') | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const listAllSpy = jest.spyOn(mailRuleService, 'listAll') | ||||
|     deleteSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('error deleting mail rule')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail rule') | ||||
|   }) | ||||
|  | ||||
|   it('should support edit permissions on mail rule objects', () => { | ||||
|     completeSetup() | ||||
|     const perms = { | ||||
|       owner: 99, | ||||
|       set_permissions: { | ||||
|         view: { | ||||
|           users: [1], | ||||
|           groups: [2], | ||||
|         }, | ||||
|         change: { | ||||
|           users: [3], | ||||
|           groups: [4], | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const rulePatchSpy = jest.spyOn(mailRuleService, 'patch') | ||||
|     component.editPermissions(mailRules[0] as PaperlessMailRule) | ||||
|     expect(modal).not.toBeUndefined() | ||||
|     let dialog = modal.componentInstance as PermissionsDialogComponent | ||||
|     expect(dialog.object).toEqual(mailRules[0]) | ||||
|  | ||||
|     rulePatchSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('error saving perms')) | ||||
|     ) | ||||
|     dialog.confirmClicked.emit(perms) | ||||
|     expect(rulePatchSpy).toHaveBeenCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as PaperlessMailRule)) | ||||
|     dialog.confirmClicked.emit(perms) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated') | ||||
|  | ||||
|     modalService.dismissAll() | ||||
|   }) | ||||
|  | ||||
|   it('should support edit permissions on mail account objects', () => { | ||||
|     completeSetup() | ||||
|     const perms = { | ||||
|       owner: 99, | ||||
|       set_permissions: { | ||||
|         view: { | ||||
|           users: [1], | ||||
|           groups: [2], | ||||
|         }, | ||||
|         change: { | ||||
|           users: [3], | ||||
|           groups: [4], | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     const accountPatchSpy = jest.spyOn(mailAccountService, 'patch') | ||||
|     component.editPermissions(mailAccounts[0] as PaperlessMailAccount) | ||||
|     expect(modal).not.toBeUndefined() | ||||
|     let dialog = modal.componentInstance as PermissionsDialogComponent | ||||
|     expect(dialog.object).toEqual(mailAccounts[0]) | ||||
|     dialog = modal.componentInstance as PermissionsDialogComponent | ||||
|     dialog.confirmClicked.emit(perms) | ||||
|     expect(accountPatchSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
							
								
								
									
										232
									
								
								src-ui/src/app/components/manage/mail/mail.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								src-ui/src/app/components/manage/mail/mail.component.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,232 @@ | ||||
| import { Component, OnInit, OnDestroy } from '@angular/core' | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { Subject, first, takeUntil } from 'rxjs' | ||||
| import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' | ||||
| import { PaperlessMailAccount } from 'src/app/data/paperless-mail-account' | ||||
| import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule' | ||||
| import { | ||||
|   PermissionsService, | ||||
|   PermissionAction, | ||||
| } from 'src/app/services/permissions.service' | ||||
| import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service' | ||||
| import { MailAccountService } from 'src/app/services/rest/mail-account.service' | ||||
| import { MailRuleService } from 'src/app/services/rest/mail-rule.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 { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component' | ||||
| import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component' | ||||
| import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' | ||||
| import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-mail', | ||||
|   templateUrl: './mail.component.html', | ||||
|   styleUrls: ['./mail.component.scss'], | ||||
| }) | ||||
| export class MailComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   mailAccounts: PaperlessMailAccount[] = [] | ||||
|   mailRules: PaperlessMailRule[] = [] | ||||
|  | ||||
|   unsubscribeNotifier: Subject<any> = new Subject() | ||||
|  | ||||
|   constructor( | ||||
|     public mailAccountService: MailAccountService, | ||||
|     public mailRuleService: MailRuleService, | ||||
|     private toastService: ToastService, | ||||
|     private modalService: NgbModal, | ||||
|     public permissionsService: PermissionsService | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.mailAccountService | ||||
|       .listAll(null, null, { full_perms: true }) | ||||
|       .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe({ | ||||
|         next: (r) => { | ||||
|           this.mailAccounts = r.results | ||||
|         }, | ||||
|         error: (e) => { | ||||
|           this.toastService.showError( | ||||
|             $localize`Error retrieving mail accounts`, | ||||
|             e | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|  | ||||
|     this.mailRuleService | ||||
|       .listAll(null, null, { full_perms: true }) | ||||
|       .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe({ | ||||
|         next: (r) => { | ||||
|           this.mailRules = r.results | ||||
|         }, | ||||
|         error: (e) => { | ||||
|           this.toastService.showError($localize`Error retrieving mail rules`, e) | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
|     this.unsubscribeNotifier.next(true) | ||||
|   } | ||||
|  | ||||
|   editMailAccount(account: PaperlessMailAccount = null) { | ||||
|     const modal = this.modalService.open(MailAccountEditDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|       size: 'xl', | ||||
|     }) | ||||
|     modal.componentInstance.dialogMode = account | ||||
|       ? EditDialogMode.EDIT | ||||
|       : EditDialogMode.CREATE | ||||
|     modal.componentInstance.object = account | ||||
|     modal.componentInstance.succeeded | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe((newMailAccount) => { | ||||
|         this.toastService.showInfo( | ||||
|           $localize`Saved account "${newMailAccount.name}".` | ||||
|         ) | ||||
|         this.mailAccountService.clearCache() | ||||
|         this.mailAccountService | ||||
|           .listAll(null, null, { full_perms: true }) | ||||
|           .subscribe((r) => { | ||||
|             this.mailAccounts = r.results | ||||
|           }) | ||||
|       }) | ||||
|     modal.componentInstance.failed | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe((e) => { | ||||
|         this.toastService.showError($localize`Error saving account.`, e) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   deleteMailAccount(account: PaperlessMailAccount) { | ||||
|     const modal = this.modalService.open(ConfirmDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
|     modal.componentInstance.title = $localize`Confirm delete mail account` | ||||
|     modal.componentInstance.messageBold = $localize`This operation will permanently delete this mail 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.mailAccountService.delete(account).subscribe({ | ||||
|         next: () => { | ||||
|           modal.close() | ||||
|           this.toastService.showInfo($localize`Deleted mail account`) | ||||
|           this.mailAccountService.clearCache() | ||||
|           this.mailAccountService | ||||
|             .listAll(null, null, { full_perms: true }) | ||||
|             .subscribe((r) => { | ||||
|               this.mailAccounts = r.results | ||||
|             }) | ||||
|         }, | ||||
|         error: (e) => { | ||||
|           this.toastService.showError( | ||||
|             $localize`Error deleting mail account.`, | ||||
|             e | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   editMailRule(rule: PaperlessMailRule = null) { | ||||
|     const modal = this.modalService.open(MailRuleEditDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|       size: 'xl', | ||||
|     }) | ||||
|     modal.componentInstance.dialogMode = rule | ||||
|       ? EditDialogMode.EDIT | ||||
|       : EditDialogMode.CREATE | ||||
|     modal.componentInstance.object = rule | ||||
|     modal.componentInstance.succeeded | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe((newMailRule) => { | ||||
|         this.toastService.showInfo($localize`Saved rule "${newMailRule.name}".`) | ||||
|         this.mailRuleService.clearCache() | ||||
|         this.mailRuleService | ||||
|           .listAll(null, null, { full_perms: true }) | ||||
|           .subscribe((r) => { | ||||
|             this.mailRules = r.results | ||||
|           }) | ||||
|       }) | ||||
|     modal.componentInstance.failed | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe((e) => { | ||||
|         this.toastService.showError($localize`Error saving rule.`, e) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   deleteMailRule(rule: PaperlessMailRule) { | ||||
|     const modal = this.modalService.open(ConfirmDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
|     modal.componentInstance.title = $localize`Confirm delete mail rule` | ||||
|     modal.componentInstance.messageBold = $localize`This operation will permanently delete this mail rule.` | ||||
|     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.mailRuleService.delete(rule).subscribe({ | ||||
|         next: () => { | ||||
|           modal.close() | ||||
|           this.toastService.showInfo($localize`Deleted mail rule`) | ||||
|           this.mailRuleService.clearCache() | ||||
|           this.mailRuleService | ||||
|             .listAll(null, null, { full_perms: true }) | ||||
|             .subscribe((r) => { | ||||
|               this.mailRules = r.results | ||||
|             }) | ||||
|         }, | ||||
|         error: (e) => { | ||||
|           this.toastService.showError($localize`Error deleting mail rule.`, e) | ||||
|         }, | ||||
|       }) | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   editPermissions(object: PaperlessMailRule | PaperlessMailAccount) { | ||||
|     const modal = this.modalService.open(PermissionsDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
|     const dialog: PermissionsDialogComponent = | ||||
|       modal.componentInstance as PermissionsDialogComponent | ||||
|     dialog.object = object | ||||
|     modal.componentInstance.confirmClicked.subscribe((permissions) => { | ||||
|       modal.componentInstance.buttonsEnabled = false | ||||
|       const service: AbstractPaperlessService< | ||||
|         PaperlessMailRule | PaperlessMailAccount | ||||
|       > = 'account' in object ? this.mailRuleService : this.mailAccountService | ||||
|       object.owner = permissions['owner'] | ||||
|       object['set_permissions'] = permissions['set_permissions'] | ||||
|       service.patch(object).subscribe({ | ||||
|         next: () => { | ||||
|           this.toastService.showInfo($localize`Permissions updated`) | ||||
|           modal.close() | ||||
|         }, | ||||
|         error: (e) => { | ||||
|           this.toastService.showError($localize`Error updating permissions`, e) | ||||
|         }, | ||||
|       }) | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   userCanEdit(obj: ObjectWithPermissions): boolean { | ||||
|     return this.permissionsService.currentUserHasObjectPermissions( | ||||
|       PermissionAction.Change, | ||||
|       obj | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   userIsOwner(obj: ObjectWithPermissions): boolean { | ||||
|     return this.permissionsService.currentUserOwnsObject(obj) | ||||
|   } | ||||
| } | ||||
| @@ -9,7 +9,12 @@ | ||||
|       <use xlink:href="assets/bootstrap-icons.svg#person-fill-lock" /> | ||||
|     </svg> <ng-container i18n>Permissions</ng-container> | ||||
|   </button> | ||||
|   <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }" i18n>Create</button> | ||||
|   <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }" i18n> | ||||
|     <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|       <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|     </svg> | ||||
|     Create | ||||
|   </button> | ||||
| </pngx-page-header> | ||||
|  | ||||
| <div class="row mb-3"> | ||||
| @@ -23,20 +28,21 @@ | ||||
|   <ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination> | ||||
| </div> | ||||
|  | ||||
| <table class="table table-striped align-middle border shadow-sm"> | ||||
| <div class="card border mb-3"> | ||||
| <table class="table table-striped align-middle shadow-sm mb-0"> | ||||
|   <thead> | ||||
|     <tr> | ||||
|       <th scope="col"> | ||||
|         <div class="form-check"> | ||||
|         <div class="form-check m-0 ms-2 me-n2"> | ||||
|           <input type="checkbox" class="form-check-input" id="all-objects" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();"> | ||||
|           <label class="form-check-label" for="all-objects"></label> | ||||
|         </div> | ||||
|       </th> | ||||
|       <th scope="col" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th> | ||||
|       <th scope="col" class="d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th> | ||||
|       <th scope="col" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th> | ||||
|       <th scope="col" *ngFor="let column of extraColumns" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th> | ||||
|       <th scope="col" i18n>Actions</th> | ||||
|       <th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th> | ||||
|       <th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th> | ||||
|       <th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th> | ||||
|       <th scope="col" class="fw-normal" *ngFor="let column of extraColumns" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th> | ||||
|       <th scope="col" class="fw-normal" i18n>Actions</th> | ||||
|     </tr> | ||||
|   </thead> | ||||
|   <tbody> | ||||
| @@ -48,12 +54,12 @@ | ||||
|     </tr> | ||||
|     <tr *ngFor="let object of data" (click)="toggleSelected(object, $event); $event.stopPropagation();"> | ||||
|       <td> | ||||
|         <div class="form-check"> | ||||
|         <div class="form-check m-0 ms-2 me-n2"> | ||||
|           <input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();"> | ||||
|           <label class="form-check-label" for="{{typeName}}{{object.id}}"></label> | ||||
|         </div> | ||||
|       </td> | ||||
|       <td scope="row">{{ object.name }}</td> | ||||
|       <td scope="row"><button class="btn btn-link ms-0 ps-0" (click)="openEditDialog(object)">{{ object.name }}</button> </td> | ||||
|       <td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td> | ||||
|       <td scope="row">{{ object.document_count }}</td> | ||||
|       <td scope="row" *ngFor="let column of extraColumns"> | ||||
| @@ -96,6 +102,7 @@ | ||||
|     </tr> | ||||
|   </tbody> | ||||
| </table> | ||||
| </div> | ||||
|  | ||||
| <div class="d-flex mb-2" *ngIf="!isLoading"> | ||||
|   <div *ngIf="collectionSize > 0"> | ||||
|   | ||||
| @@ -2,3 +2,11 @@ | ||||
| .d-block.d-sm-none .dropdown-toggle::after { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| tbody tr:last-child td { | ||||
|     border-bottom: none !important; | ||||
| } | ||||
|  | ||||
| .form-check { | ||||
|     min-height: 0; | ||||
| } | ||||
|   | ||||
| @@ -172,7 +172,7 @@ describe('ManagementListComponent', () => { | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const reloadSpy = jest.spyOn(component, 'reloadData') | ||||
|  | ||||
|     const editButton = fixture.debugElement.queryAll(By.css('button'))[5] | ||||
|     const editButton = fixture.debugElement.queryAll(By.css('button'))[6] | ||||
|     editButton.triggerEventHandler('click') | ||||
|  | ||||
|     expect(modal).not.toBeUndefined() | ||||
| @@ -198,7 +198,7 @@ describe('ManagementListComponent', () => { | ||||
|     const deleteSpy = jest.spyOn(tagService, 'delete') | ||||
|     const reloadSpy = jest.spyOn(component, 'reloadData') | ||||
|  | ||||
|     const deleteButton = fixture.debugElement.queryAll(By.css('button'))[6] | ||||
|     const deleteButton = fixture.debugElement.queryAll(By.css('button'))[7] | ||||
|     deleteButton.triggerEventHandler('click') | ||||
|  | ||||
|     expect(modal).not.toBeUndefined() | ||||
| @@ -218,7 +218,7 @@ describe('ManagementListComponent', () => { | ||||
|  | ||||
|   it('should support quick filter for objects', () => { | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     const filterButton = fixture.debugElement.queryAll(By.css('button'))[4] | ||||
|     const filterButton = fixture.debugElement.queryAll(By.css('button'))[5] | ||||
|     filterButton.triggerEventHandler('click') | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() }, | ||||
|   | ||||
| @@ -1,665 +0,0 @@ | ||||
| import { ViewportScroller, 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 { 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' | ||||
| import { routes } from 'src/app/app-routing.module' | ||||
| import { PaperlessMailAccount } from 'src/app/data/paperless-mail-account' | ||||
| import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule' | ||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
| 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 { MailAccountService } from 'src/app/services/rest/mail-account.service' | ||||
| import { MailRuleService } from 'src/app/services/rest/mail-rule.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| 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 { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component' | ||||
| import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-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' | ||||
| 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.component' | ||||
| import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' | ||||
| import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component' | ||||
| import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' | ||||
|  | ||||
| const savedViews = [ | ||||
|   { id: 1, name: 'view1' }, | ||||
|   { id: 2, name: 'view2' }, | ||||
| ] | ||||
| 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' }, | ||||
| ] | ||||
| const mailAccounts = [ | ||||
|   { id: 1, name: 'account1' }, | ||||
|   { id: 2, name: 'account2' }, | ||||
| ] | ||||
| const mailRules = [ | ||||
|   { id: 1, name: 'rule1', owner: 1, account: 1 }, | ||||
|   { id: 2, name: 'rule2', owner: 2, account: 2 }, | ||||
| ] | ||||
|  | ||||
| describe('SettingsComponent', () => { | ||||
|   let component: SettingsComponent | ||||
|   let fixture: ComponentFixture<SettingsComponent> | ||||
|   let modalService: NgbModal | ||||
|   let router: Router | ||||
|   let settingsService: SettingsService | ||||
|   let savedViewService: SavedViewService | ||||
|   let activatedRoute: ActivatedRoute | ||||
|   let viewportScroller: ViewportScroller | ||||
|   let toastService: ToastService | ||||
|   let userService: UserService | ||||
|   let permissionsService: PermissionsService | ||||
|   let groupService: GroupService | ||||
|   let mailAccountService: MailAccountService | ||||
|   let mailRuleService: MailRuleService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
|       declarations: [ | ||||
|         SettingsComponent, | ||||
|         PageHeaderComponent, | ||||
|         IfPermissionsDirective, | ||||
|         CustomDatePipe, | ||||
|         ConfirmDialogComponent, | ||||
|         CheckComponent, | ||||
|         ColorComponent, | ||||
|         SafeHtmlPipe, | ||||
|         SelectComponent, | ||||
|         TextComponent, | ||||
|         PasswordComponent, | ||||
|         NumberComponent, | ||||
|         TagsComponent, | ||||
|         MailAccountEditDialogComponent, | ||||
|         MailRuleEditDialogComponent, | ||||
|         PermissionsUserComponent, | ||||
|         PermissionsGroupComponent, | ||||
|         IfOwnerDirective, | ||||
|         PermissionsDialogComponent, | ||||
|         PermissionsFormComponent, | ||||
|       ], | ||||
|       providers: [CustomDatePipe, DatePipe, PermissionsGuard], | ||||
|       imports: [ | ||||
|         NgbModule, | ||||
|         HttpClientTestingModule, | ||||
|         RouterTestingModule.withRoutes(routes), | ||||
|         FormsModule, | ||||
|         ReactiveFormsModule, | ||||
|         NgbAlertModule, | ||||
|         NgSelectModule, | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     modalService = TestBed.inject(NgbModal) | ||||
|     router = TestBed.inject(Router) | ||||
|     activatedRoute = TestBed.inject(ActivatedRoute) | ||||
|     viewportScroller = TestBed.inject(ViewportScroller) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     settingsService = TestBed.inject(SettingsService) | ||||
|     settingsService.currentUser = { id: 99, username: 'user99' } | ||||
|     userService = TestBed.inject(UserService) | ||||
|     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) | ||||
|     savedViewService = TestBed.inject(SavedViewService) | ||||
|     mailAccountService = TestBed.inject(MailAccountService) | ||||
|     mailRuleService = TestBed.inject(MailRuleService) | ||||
|   }) | ||||
|  | ||||
|   function completeSetup(excludeService = null) { | ||||
|     if (excludeService !== userService) { | ||||
|       jest.spyOn(userService, 'listAll').mockReturnValue( | ||||
|         of({ | ||||
|           all: users.map((u) => u.id), | ||||
|           count: users.length, | ||||
|           results: users.concat([]), | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|     if (excludeService !== groupService) { | ||||
|       jest.spyOn(groupService, 'listAll').mockReturnValue( | ||||
|         of({ | ||||
|           all: groups.map((g) => g.id), | ||||
|           count: groups.length, | ||||
|           results: groups.concat([]), | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|     if (excludeService !== savedViewService) { | ||||
|       jest.spyOn(savedViewService, 'listAll').mockReturnValue( | ||||
|         of({ | ||||
|           all: savedViews.map((v) => v.id), | ||||
|           count: savedViews.length, | ||||
|           results: (savedViews as PaperlessSavedView[]).concat([]), | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|     if (excludeService !== mailAccountService) { | ||||
|       jest.spyOn(mailAccountService, 'listAll').mockReturnValue( | ||||
|         of({ | ||||
|           all: mailAccounts.map((a) => a.id), | ||||
|           count: mailAccounts.length, | ||||
|           results: (mailAccounts as PaperlessMailAccount[]).concat([]), | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|     if (excludeService !== mailRuleService) { | ||||
|       jest.spyOn(mailRuleService, 'listAll').mockReturnValue( | ||||
|         of({ | ||||
|           all: mailRules.map((r) => r.id), | ||||
|           count: mailRules.length, | ||||
|           results: (mailRules as PaperlessMailRule[]).concat([]), | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|  | ||||
|     fixture = TestBed.createComponent(SettingsComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   } | ||||
|  | ||||
|   it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => { | ||||
|     completeSetup() | ||||
|     const navigateSpy = jest.spyOn(router, 'navigate') | ||||
|     const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) | ||||
|     tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     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', 'mail']) | ||||
|     tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     expect(navigateSpy).toHaveBeenCalledWith(['settings', 'usersgroups']) | ||||
|  | ||||
|     const initSpy = jest.spyOn(component, 'initialize') | ||||
|     component.isDirty = true // mock dirty | ||||
|     navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty | ||||
|     tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general']) | ||||
|     expect(initSpy).not.toHaveBeenCalled() | ||||
|  | ||||
|     navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty | ||||
|     tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications']) | ||||
|     expect(initSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support direct link to tab by URL, scroll if needed', () => { | ||||
|     completeSetup() | ||||
|     jest | ||||
|       .spyOn(activatedRoute, 'paramMap', 'get') | ||||
|       .mockReturnValue(of(convertToParamMap({ section: 'mail' }))) | ||||
|     activatedRoute.snapshot.fragment = '#mail' | ||||
|     const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') | ||||
|     component.ngOnInit() | ||||
|     expect(component.activeNavID).toEqual(4) // Mail | ||||
|     component.ngAfterViewInit() | ||||
|     expect(scrollSpy).toHaveBeenCalledWith('#mail') | ||||
|   }) | ||||
|  | ||||
|   it('should lazy load tab data', () => { | ||||
|     completeSetup() | ||||
|     const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) | ||||
|  | ||||
|     expect(component.savedViews).toBeUndefined() | ||||
|     tabButtons[2].nativeElement.dispatchEvent( | ||||
|       new MouseEvent('mouseover', { bubbles: true }) | ||||
|     ) | ||||
|     expect(component.savedViews).not.toBeUndefined() | ||||
|  | ||||
|     expect(component.mailAccounts).toBeUndefined() | ||||
|     tabButtons[3].nativeElement.dispatchEvent( | ||||
|       new MouseEvent('mouseover', { bubbles: true }) | ||||
|     ) | ||||
|     expect(component.mailAccounts).not.toBeUndefined() | ||||
|  | ||||
|     expect(component.groups).toBeUndefined() | ||||
|     tabButtons[4].nativeElement.dispatchEvent( | ||||
|       new MouseEvent('mouseover', { bubbles: true }) | ||||
|     ) | ||||
|     expect(component.groups).not.toBeUndefined() | ||||
|   }) | ||||
|  | ||||
|   it('should support save saved views, show error', () => { | ||||
|     completeSetup() | ||||
|     component.maybeInitializeTab(3) // SavedViews | ||||
|  | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastSpy = jest.spyOn(toastService, 'show') | ||||
|     const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany') | ||||
|  | ||||
|     // saved views error first | ||||
|     savedViewPatchSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('unable to save saved views')) | ||||
|     ) | ||||
|     component.saveSettings() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     expect(savedViewPatchSpy).toHaveBeenCalled() | ||||
|     toastSpy.mockClear() | ||||
|     toastErrorSpy.mockClear() | ||||
|     savedViewPatchSpy.mockClear() | ||||
|  | ||||
|     // succeed saved views | ||||
|     savedViewPatchSpy.mockReturnValueOnce( | ||||
|       of(savedViews as PaperlessSavedView[]) | ||||
|     ) | ||||
|     component.saveSettings() | ||||
|     expect(toastErrorSpy).not.toHaveBeenCalled() | ||||
|     expect(savedViewPatchSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support save local settings updating appearance settings and calling API, show error', () => { | ||||
|     completeSetup() | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastSpy = jest.spyOn(toastService, 'show') | ||||
|     const storeSpy = jest.spyOn(settingsService, 'storeSettings') | ||||
|     const appearanceSettingsSpy = jest.spyOn( | ||||
|       settingsService, | ||||
|       'updateAppearanceSettings' | ||||
|     ) | ||||
|     const setSpy = jest.spyOn(settingsService, 'set') | ||||
|  | ||||
|     // error first | ||||
|     storeSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('unable to save settings')) | ||||
|     ) | ||||
|     component.saveSettings() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     expect(storeSpy).toHaveBeenCalled() | ||||
|     expect(appearanceSettingsSpy).not.toHaveBeenCalled() | ||||
|     expect(setSpy).toHaveBeenCalledTimes(24) | ||||
|  | ||||
|     // succeed | ||||
|     storeSpy.mockReturnValueOnce(of(true)) | ||||
|     component.saveSettings() | ||||
|     expect(toastSpy).toHaveBeenCalled() | ||||
|     expect(appearanceSettingsSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should offer reload if settings changes require', () => { | ||||
|     completeSetup() | ||||
|     let toast: Toast | ||||
|     toastService.getToasts().subscribe((t) => (toast = t[0])) | ||||
|     component.initialize(true) // reset | ||||
|     component.store.getValue()['displayLanguage'] = 'en-US' | ||||
|     component.store.getValue()['updateCheckingEnabled'] = false | ||||
|     component.settingsForm.value.displayLanguage = 'en-GB' | ||||
|     component.settingsForm.value.updateCheckingEnabled = true | ||||
|     jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true)) | ||||
|     component.saveSettings() | ||||
|     expect(toast.actionName).toEqual('Reload now') | ||||
|   }) | ||||
|  | ||||
|   it('should allow setting theme color, visually apply change immediately but not save', () => { | ||||
|     completeSetup() | ||||
|     const appearanceSpy = jest.spyOn( | ||||
|       settingsService, | ||||
|       'updateAppearanceSettings' | ||||
|     ) | ||||
|     const colorInput = fixture.debugElement.query(By.directive(ColorComponent)) | ||||
|     colorInput.query(By.css('input')).nativeElement.value = '#ff0000' | ||||
|     colorInput | ||||
|       .query(By.css('input')) | ||||
|       .nativeElement.dispatchEvent(new Event('change')) | ||||
|     fixture.detectChanges() | ||||
|     expect(appearanceSpy).toHaveBeenCalled() | ||||
|     expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('') | ||||
|     component.clearThemeColor() | ||||
|   }) | ||||
|  | ||||
|   it('should support delete saved view', () => { | ||||
|     completeSetup() | ||||
|     component.maybeInitializeTab(3) // SavedViews | ||||
|     const toastSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const deleteSpy = jest.spyOn(savedViewService, 'delete') | ||||
|     deleteSpy.mockReturnValue(of(true)) | ||||
|     component.deleteSavedView(savedViews[0] as PaperlessSavedView) | ||||
|     expect(deleteSpy).toHaveBeenCalled() | ||||
|     expect(toastSpy).toHaveBeenCalledWith( | ||||
|       `Saved view "${savedViews[0].name}" deleted.` | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   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() | ||||
|     component.maybeInitializeTab(5) // UsersGroups | ||||
|     expect(component.getGroupName(1)).toEqual(groups[0].name) | ||||
|     expect(component.getGroupName(11)).toEqual('') | ||||
|   }) | ||||
|  | ||||
|   it('should show errors on load if load mailAccounts failure', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     jest | ||||
|       .spyOn(mailAccountService, 'listAll') | ||||
|       .mockImplementation(() => | ||||
|         throwError(() => new Error('failed to load mail accounts')) | ||||
|       ) | ||||
|     completeSetup(mailAccountService) | ||||
|     const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) | ||||
|     tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should show errors on load if load mailRules failure', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     jest | ||||
|       .spyOn(mailRuleService, 'listAll') | ||||
|       .mockImplementation(() => | ||||
|         throwError(() => new Error('failed to load mail rules')) | ||||
|       ) | ||||
|     completeSetup(mailRuleService) | ||||
|     const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) | ||||
|     tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab | ||||
|     fixture.detectChanges() | ||||
|     // tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|   }) | ||||
|  | ||||
|   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) | ||||
|     const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) | ||||
|     tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab | ||||
|     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) | ||||
|     const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) | ||||
|     tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support edit / create mail account, show error if needed', () => { | ||||
|     completeSetup() | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.editMailAccount(mailAccounts[0] as PaperlessMailAccount) | ||||
|     const editDialog = modal.componentInstance as MailAccountEditDialogComponent | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     editDialog.succeeded.emit(mailAccounts[0]) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
|       `Saved account "${mailAccounts[0].name}".` | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   it('should support delete mail account, show error if needed', () => { | ||||
|     completeSetup() | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount) | ||||
|     const deleteDialog = modal.componentInstance as ConfirmDialogComponent | ||||
|     const deleteSpy = jest.spyOn(mailAccountService, 'delete') | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const listAllSpy = jest.spyOn(mailAccountService, 'listAll') | ||||
|     deleteSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('error deleting mail account')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail account') | ||||
|   }) | ||||
|  | ||||
|   it('should support edit / create mail rule, show error if needed', () => { | ||||
|     completeSetup() | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.editMailRule(mailRules[0] as PaperlessMailRule) | ||||
|     const editDialog = modal.componentInstance as MailRuleEditDialogComponent | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     editDialog.succeeded.emit(mailRules[0]) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
|       `Saved rule "${mailRules[0].name}".` | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   it('should support delete mail rule, show error if needed', () => { | ||||
|     completeSetup() | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.deleteMailRule(mailRules[0] as PaperlessMailRule) | ||||
|     const deleteDialog = modal.componentInstance as ConfirmDialogComponent | ||||
|     const deleteSpy = jest.spyOn(mailRuleService, 'delete') | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const listAllSpy = jest.spyOn(mailRuleService, 'listAll') | ||||
|     deleteSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('error deleting mail rule')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail rule') | ||||
|   }) | ||||
|  | ||||
|   it('should support edit permissions on mail rule objects', () => { | ||||
|     completeSetup() | ||||
|     const perms = { | ||||
|       owner: 99, | ||||
|       set_permissions: { | ||||
|         view: { | ||||
|           users: [1], | ||||
|           groups: [2], | ||||
|         }, | ||||
|         change: { | ||||
|           users: [3], | ||||
|           groups: [4], | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const rulePatchSpy = jest.spyOn(mailRuleService, 'patch') | ||||
|     component.editPermissions(mailRules[0] as PaperlessMailRule) | ||||
|     expect(modal).not.toBeUndefined() | ||||
|     let dialog = modal.componentInstance as PermissionsDialogComponent | ||||
|     expect(dialog.object).toEqual(mailRules[0]) | ||||
|  | ||||
|     rulePatchSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('error saving perms')) | ||||
|     ) | ||||
|     dialog.confirmClicked.emit(perms) | ||||
|     expect(rulePatchSpy).toHaveBeenCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as PaperlessMailRule)) | ||||
|     dialog.confirmClicked.emit(perms) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated') | ||||
|  | ||||
|     modalService.dismissAll() | ||||
|   }) | ||||
|  | ||||
|   it('should support edit permissions on mail account objects', () => { | ||||
|     completeSetup() | ||||
|     const perms = { | ||||
|       owner: 99, | ||||
|       set_permissions: { | ||||
|         view: { | ||||
|           users: [1], | ||||
|           groups: [2], | ||||
|         }, | ||||
|         change: { | ||||
|           users: [3], | ||||
|           groups: [4], | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     const accountPatchSpy = jest.spyOn(mailAccountService, 'patch') | ||||
|     component.editPermissions(mailAccounts[0] as PaperlessMailAccount) | ||||
|     expect(modal).not.toBeUndefined() | ||||
|     let dialog = modal.componentInstance as PermissionsDialogComponent | ||||
|     expect(dialog.object).toEqual(mailAccounts[0]) | ||||
|     dialog = modal.componentInstance as PermissionsDialogComponent | ||||
|     dialog.confirmClicked.emit(perms) | ||||
|     expect(accountPatchSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -50,6 +50,8 @@ export class MailAccountService extends AbstractPaperlessService<PaperlessMailAc | ||||
|   } | ||||
|  | ||||
|   test(o: PaperlessMailAccount) { | ||||
|     return this.http.post(this.getResourceUrl() + 'test/', o) | ||||
|     const account = Object.assign({}, o) | ||||
|     delete account['set_permissions'] | ||||
|     return this.http.post(this.getResourceUrl() + 'test/', account) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -50,6 +50,11 @@ svg.logo { | ||||
|   color: var(--bs-body-color); | ||||
| } | ||||
|  | ||||
| .list-group-item .btn-link, | ||||
| table .btn-link { | ||||
|   font-size: 1em; | ||||
| } | ||||
|  | ||||
| .bg-body { | ||||
|   background-color: var(--bs-body-bg); | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon