Move mail settings to its own component

This commit is contained in:
shamoon 2023-09-22 11:28:23 -07:00
parent ff4e0e1d17
commit da32877635
13 changed files with 771 additions and 751 deletions

View File

@ -2,24 +2,6 @@ import { test, expect } from '@playwright/test'
const REQUESTS_HAR = 'e2e/admin/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
})
test('should activate / deactivate save button when settings change', async ({
page,
}) => {
@ -89,11 +71,6 @@ test('should support tab direct navigation', async ({ page }) => {
'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' })

View File

@ -22,6 +22,7 @@ import {
PermissionType,
} from './services/permissions.service'
import { ConsumptionTemplatesListComponent } from './components/manage/consumption-templates-list/consumption-templates-list.component'
import { MailComponent } from './components/manage/mail/mail.component'
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
@ -143,6 +144,10 @@ export const routes: Routes = [
},
},
},
{
path: 'settings/mail',
redirectTo: '/mail',
},
{
path: 'settings',
component: SettingsComponent,
@ -167,11 +172,6 @@ export const routes: Routes = [
},
},
},
{
path: 'settings/:section',
component: SettingsComponent,
canDeactivate: [DirtyFormGuard],
},
{
path: 'tasks',
component: TasksComponent,
@ -194,6 +194,17 @@ export const routes: Routes = [
},
},
},
{
path: 'mail',
component: MailComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.MailAccount,
},
},
},
],
},

View File

@ -179,6 +179,14 @@ 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.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 +197,7 @@ export class AppComponent implements OnInit, OnDestroy {
},
{
anchorId: 'tour.settings',
content: $localize`Check out the settings for various tweaks to the web app, toggle settings for saved views or setup e-mail checking.`,
content: $localize`Check out the settings for various tweaks to the web app, toggle settings for saved views or manage users.`,
route: '/settings',
backdropConfig: {
offset: 0,

View File

@ -125,6 +125,7 @@ import localeSv from '@angular/common/locales/sv'
import localeTr from '@angular/common/locales/tr'
import localeUk from '@angular/common/locales/uk'
import localeZh from '@angular/common/locales/zh'
import { MailComponent } from './components/manage/mail/mail.component'
registerLocaleData(localeAf)
registerLocaleData(localeAr)
@ -237,6 +238,7 @@ function initializeApp(settings: SettingsService) {
ShareLinksDropdownComponent,
ConsumptionTemplatesListComponent,
ConsumptionTemplateEditDialogComponent,
MailComponent,
],
imports: [
BrowserModule,

View File

@ -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>
@ -261,7 +261,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>
@ -311,97 +311,7 @@
</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)">
<li [ngbNavItem]="SettingsNavIDs.UsersGroups" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a ngbNavLink i18n>Users & Groups</a>
<ng-template ngbNavContent>

View File

@ -20,8 +20,6 @@ import {
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'
@ -30,16 +28,12 @@ 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'
@ -52,8 +46,6 @@ 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 = [
@ -68,14 +60,6 @@ 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
@ -90,8 +74,6 @@ describe('SettingsComponent', () => {
let userService: UserService
let permissionsService: PermissionsService
let groupService: GroupService
let mailAccountService: MailAccountService
let mailRuleService: MailRuleService
beforeEach(async () => {
TestBed.configureTestingModule({
@ -109,13 +91,9 @@ describe('SettingsComponent', () => {
PasswordComponent,
NumberComponent,
TagsComponent,
MailAccountEditDialogComponent,
MailRuleEditDialogComponent,
PermissionsUserComponent,
PermissionsGroupComponent,
IfOwnerDirective,
PermissionsDialogComponent,
PermissionsFormComponent,
],
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [
@ -135,7 +113,7 @@ describe('SettingsComponent', () => {
viewportScroller = TestBed.inject(ViewportScroller)
toastService = TestBed.inject(ToastService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser = users[0]
userService = TestBed.inject(UserService)
permissionsService = TestBed.inject(PermissionsService)
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
@ -147,8 +125,6 @@ describe('SettingsComponent', () => {
.mockReturnValue(true)
groupService = TestBed.inject(GroupService)
savedViewService = TestBed.inject(SavedViewService)
mailAccountService = TestBed.inject(MailAccountService)
mailRuleService = TestBed.inject(MailRuleService)
})
function completeSetup(excludeService = null) {
@ -179,24 +155,6 @@ describe('SettingsComponent', () => {
})
)
}
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
@ -212,8 +170,6 @@ describe('SettingsComponent', () => {
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')
@ -233,41 +189,17 @@ describe('SettingsComponent', () => {
completeSetup()
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ section: 'mail' })))
activatedRoute.snapshot.fragment = '#mail'
.mockReturnValue(of(convertToParamMap({ section: 'usersgroups' })))
activatedRoute.snapshot.fragment = '#usersgroups'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit()
expect(component.activeNavID).toEqual(4) // Mail
expect(component.activeNavID).toEqual(4) // Users & Groups
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()
expect(scrollSpy).toHaveBeenCalledWith('#usersgroups')
})
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')
@ -295,6 +227,7 @@ describe('SettingsComponent', () => {
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')
@ -309,6 +242,7 @@ describe('SettingsComponent', () => {
throwError(() => new Error('unable to save settings'))
)
component.saveSettings()
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
@ -323,6 +257,7 @@ describe('SettingsComponent', () => {
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
@ -354,7 +289,6 @@ describe('SettingsComponent', () => {
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))
@ -462,40 +396,10 @@ describe('SettingsComponent', () => {
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
@ -505,7 +409,7 @@ describe('SettingsComponent', () => {
)
completeSetup(userService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
})
@ -519,147 +423,8 @@ describe('SettingsComponent', () => {
)
completeSetup(groupService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab
tabButtons[3].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()
})
})

View File

@ -1,65 +1,56 @@
import { ViewportScroller } from '@angular/common'
import {
Component,
OnInit,
AfterViewInit,
OnDestroy,
Inject,
LOCALE_ID,
OnInit,
OnDestroy,
AfterViewInit,
} from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import {
LanguageOption,
SettingsService,
} from 'src/app/services/settings.service'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import {
Observable,
Subscription,
BehaviorSubject,
first,
tap,
takeUntil,
Subject,
} from 'rxjs'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { FormGroup, FormControl } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { ViewportScroller } from '@angular/common'
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { NgbModal, NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
import { UserService } from 'src/app/services/rest/user.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { PaperlessUser } from 'src/app/data/paperless-user'
import { PaperlessGroup } from 'src/app/data/paperless-group'
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
import { PaperlessMailAccount } from 'src/app/data/paperless-mail-account'
import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule'
import { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
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 { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
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 { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
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,
PermissionsService,
} from 'src/app/services/permissions.service'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-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 { 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'
enum SettingsNavIDs {
General = 1,
Notifications = 2,
SavedViews = 3,
Mail = 4,
UsersGroups = 5,
UsersGroups = 4,
}
@Component({
@ -78,9 +69,6 @@ export class SettingsComponent
usersGroup = new FormGroup({})
groupsGroup = new FormGroup({})
mailAccountGroup = new FormGroup({})
mailRuleGroup = new FormGroup({})
settingsForm = new FormGroup({
bulkEditConfirmationDialogs: new FormControl(null),
bulkEditApplyOnClose: new FormControl(null),
@ -111,16 +99,10 @@ export class SettingsComponent
savedViewsWarnOnUnsavedChange: new FormControl(null),
savedViews: this.savedViewGroup,
mailAccounts: this.mailAccountGroup,
mailRules: this.mailRuleGroup,
})
savedViews: PaperlessSavedView[]
mailAccounts: PaperlessMailAccount[]
mailRules: PaperlessMailRule[]
store: BehaviorSubject<any>
storeSub: Subscription
isDirty$: Observable<boolean>
@ -141,8 +123,6 @@ export class SettingsComponent
constructor(
public savedViewService: SavedViewService,
public mailAccountService: MailAccountService,
public mailRuleService: MailRuleService,
private documentListViewService: DocumentListViewService,
private toastService: ToastService,
private settings: SettingsService,
@ -165,25 +145,66 @@ export class SettingsComponent
ngOnInit() {
this.initialize()
this.activatedRoute.paramMap.subscribe((paramMap) => {
const section = paramMap.get('section')
if (section === null) {
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.User
)
) {
this.getUsers()
this.usersService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.users = r.results
this.initialize(false)
},
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
this.initialize(false)
},
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]
this.maybeInitializeTab(this.activeNavID)
}
}
})
@ -256,13 +277,10 @@ export class SettingsComponent
usersGroup: {},
groupsGroup: {},
savedViews: {},
mailAccounts: {},
mailRules: {},
}
}
onNavChange(navChangeEvent: NgbNavChangeEvent) {
this.maybeInitializeTab(navChangeEvent.nextId)
const [foundNavIDkey] = Object.entries(SettingsNavIDs).find(
([, navIDValue]) => navIDValue == navChangeEvent.nextId
)
@ -279,83 +297,6 @@ export class SettingsComponent
})
}
// Load tab contents 'on demand', either on mouseover or focusin (i.e. before click) or called from nav change event
maybeInitializeTab(navID: number): void {
if (navID == SettingsNavIDs.SavedViews && !this.savedViews) {
this.savedViewService.listAll().subscribe((r) => {
this.savedViews = r.results
this.initialize(false)
})
} else if (
(navID == SettingsNavIDs.UsersGroups ||
navID == SettingsNavIDs.General) &&
(!this.users || !this.groups)
) {
if (!this.users) this.getUsers()
this.groupsService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.groups = r.results
this.initialize(false)
},
error: (e) => {
this.toastService.showError($localize`Error retrieving groups`, e)
},
})
} else if (
navID == SettingsNavIDs.Mail &&
(!this.mailAccounts || !this.mailRules)
) {
this.mailAccountService
.listAll(null, null, { full_perms: true })
.pipe(first())
.subscribe({
next: (r) => {
this.mailAccounts = r.results
this.mailRuleService
.listAll(null, null, { full_perms: true })
.pipe(first())
.subscribe({
next: (r) => {
this.mailRules = r.results
this.initialize(false)
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving mail rules`,
e
)
},
})
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving mail accounts`,
e
)
},
})
}
}
private getUsers() {
this.usersService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.users = r.results
this.initialize(false)
},
error: (e) => {
this.toastService.showError($localize`Error retrieving users`, e)
},
})
}
initialize(resetSettings: boolean = true) {
this.unsubscribeNotifier.next(true)
@ -414,6 +355,7 @@ export class SettingsComponent
)
}
}
if (this.groups) {
this.emptyGroup(this.groupsGroup)
for (let group of this.groups) {
@ -433,81 +375,6 @@ export class SettingsComponent
}
}
if (this.mailAccounts && this.mailRules) {
this.emptyGroup(this.mailAccountGroup)
this.emptyGroup(this.mailRuleGroup)
for (let account of this.mailAccounts) {
storeData.mailAccounts[account.id.toString()] = {
id: account.id,
name: account.name,
imap_server: account.imap_server,
imap_port: account.imap_port,
imap_security: account.imap_security,
username: account.username,
password: account.password,
character_set: account.character_set,
}
this.mailAccountGroup.addControl(
account.id.toString(),
new FormGroup({
id: new FormControl(null),
name: new FormControl(null),
imap_server: new FormControl(null),
imap_port: new FormControl(null),
imap_security: new FormControl(null),
username: new FormControl(null),
password: new FormControl(null),
character_set: new FormControl(null),
})
)
}
for (let rule of this.mailRules) {
storeData.mailRules[rule.id.toString()] = {
name: rule.name,
account: rule.account,
folder: rule.folder,
filter_from: rule.filter_from,
filter_to: rule.filter_to,
filter_subject: rule.filter_subject,
filter_body: rule.filter_body,
filter_attachment_filename: rule.filter_attachment_filename,
maximum_age: rule.maximum_age,
attachment_type: rule.attachment_type,
action: rule.action,
action_parameter: rule.action_parameter,
assign_title_from: rule.assign_title_from,
assign_tags: rule.assign_tags,
assign_document_type: rule.assign_document_type,
assign_correspondent_from: rule.assign_correspondent_from,
assign_correspondent: rule.assign_correspondent,
}
this.mailRuleGroup.addControl(
rule.id.toString(),
new FormGroup({
name: new FormControl(null),
account: new FormControl(null),
folder: new FormControl(null),
filter_from: new FormControl(null),
filter_to: new FormControl(null),
filter_subject: new FormControl(null),
filter_body: new FormControl(null),
filter_attachment_filename: new FormControl(null),
maximum_age: new FormControl(null),
attachment_type: new FormControl(null),
action: new FormControl(null),
action_parameter: new FormControl(null),
assign_title_from: new FormControl(null),
assign_tags: new FormControl(null),
assign_document_type: new FormControl(null),
assign_correspondent_from: new FormControl(null),
assign_correspondent: new FormControl(null),
})
)
}
}
this.store = new BehaviorSubject(storeData)
this.storeSub = this.store.asObservable().subscribe((state) => {
@ -722,17 +589,17 @@ export class SettingsComponent
x.push(this.savedViewGroup.value[id])
}
if (x.length > 0) {
this.savedViewService.patchMany(x).subscribe(
(s) => {
this.savedViewService.patchMany(x).subscribe({
next: () => {
this.saveLocalSettings()
},
(error) => {
error: (error) => {
this.toastService.showError(
$localize`Error while storing settings on server.`,
error
)
}
)
},
})
} else {
this.saveLocalSettings()
}
@ -874,152 +741,4 @@ export class SettingsComponent
getGroupName(id: number): string {
return this.groups?.find((g) => g.id === id)?.name ?? ''
}
editMailAccount(account: PaperlessMailAccount) {
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
this.initialize()
})
})
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
this.initialize(true)
})
},
error: (e) => {
this.toastService.showError(
$localize`Error deleting mail account.`,
e
)
},
})
})
}
editMailRule(rule: PaperlessMailRule) {
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
this.initialize(true)
})
})
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
this.initialize(true)
})
},
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)
},
})
})
}
}

View File

@ -162,12 +162,26 @@
</svg><span>&nbsp;<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>&nbsp;<ng-container i18n>Mail</ng-container></span>
</a>
</li>
</ul>
<h6 class="sidebar-heading px-3 mt-3 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>&nbsp;<ng-container i18n>Settings</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>
@ -183,13 +197,6 @@
</svg><span>&nbsp;<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>&nbsp;<ng-container i18n>Settings</ng-container></span>
</a>
</li>
</ul>
<h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted">

View File

@ -0,0 +1,83 @@
<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-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-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">
<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-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>
<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>

View 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()
})
})

View 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)
}
}

View File

@ -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)
}
}