Merge pull request #2147 from paperless-ngx/feature-permissions

Feature: multi-user permissions
This commit is contained in:
shamoon 2023-02-17 07:21:18 -08:00 committed by GitHub
commit 21eb253c57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
139 changed files with 5081 additions and 779 deletions

View File

@ -61,6 +61,9 @@ bleach = "*"
scipy = "==1.8.1" scipy = "==1.8.1"
# Newer versions aren't builting yet (see https://www.piwheels.org/project/cryptography/) # Newer versions aren't builting yet (see https://www.piwheels.org/project/cryptography/)
cryptography = "==38.0.1" cryptography = "==38.0.1"
django-guardian = "*"
djangorestframework-guardian = "*"
# Locked version until https://github.com/django/channels_redis/issues/332 # Locked version until https://github.com/django/channels_redis/issues/332
# is resolved # is resolved
channels-redis = "==3.4.1" channels-redis = "==3.4.1"

32
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "d70848276d3ac35fa361c15ac2d634344cdb08618790502669eee209fc16fa00" "sha256": "99f415c5ce96020dc3fcb137dc15d47cc5431686bdce1ca42e6254a2719060a8"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": {}, "requires": {},
@ -313,7 +313,7 @@
"sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845",
"sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"
], ],
"markers": "python_full_version >= '3.6.0'", "markers": "python_version >= '3.6'",
"version": "==2.1.1" "version": "==2.1.1"
}, },
"click": { "click": {
@ -329,7 +329,7 @@
"sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667", "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667",
"sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035" "sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035"
], ],
"markers": "python_full_version >= '3.6.2' and python_full_version < '4.0.0'", "markers": "python_version < '4' and python_full_version >= '3.6.2'",
"version": "==0.3.0" "version": "==0.3.0"
}, },
"click-plugins": { "click-plugins": {
@ -472,6 +472,14 @@
"index": "pypi", "index": "pypi",
"version": "==22.1" "version": "==22.1"
}, },
"django-guardian": {
"hashes": [
"sha256:440ca61358427e575323648b25f8384739e54c38b3d655c81d75e0cd0d61b697",
"sha256:c58a68ae76922d33e6bdc0e69af1892097838de56e93e78a8361090bcd9f89a0"
],
"index": "pypi",
"version": "==2.4.0"
},
"djangorestframework": { "djangorestframework": {
"hashes": [ "hashes": [
"sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8",
@ -480,6 +488,14 @@
"index": "pypi", "index": "pypi",
"version": "==3.14.0" "version": "==3.14.0"
}, },
"djangorestframework-guardian": {
"hashes": [
"sha256:1883756452d9bfcc2a51fb4e039a6837a8f6697c756447aa83af085749b59330",
"sha256:3bd3dd6ea58e1bceca5048faf6f8b1a93bb5dcff30ba5eb91b9a0e190a48a0c7"
],
"index": "pypi",
"version": "==0.3.0"
},
"filelock": { "filelock": {
"hashes": [ "hashes": [
"sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de", "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de",
@ -2205,7 +2221,7 @@
"sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845",
"sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"
], ],
"markers": "python_full_version >= '3.6.0'", "markers": "python_version >= '3.6'",
"version": "==2.1.1" "version": "==2.1.1"
}, },
"click": { "click": {
@ -2401,7 +2417,7 @@
"sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874", "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874",
"sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621" "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"
], ],
"markers": "python_version >= '3.6'", "markers": "python_full_version >= '3.6.0'",
"version": "==3.3.7" "version": "==3.3.7"
}, },
"markupsafe": { "markupsafe": {
@ -2455,7 +2471,7 @@
"sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8",
"sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307" "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"
], ],
"markers": "python_version >= '3.6'", "markers": "python_full_version >= '3.6.0'",
"version": "==1.3.4" "version": "==1.3.4"
}, },
"mkdocs": { "mkdocs": {
@ -2800,7 +2816,7 @@
"sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb",
"sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069" "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"
], ],
"markers": "python_version >= '3.6'", "markers": "python_full_version >= '3.6.0'",
"version": "==0.1" "version": "==0.1"
}, },
"regex": { "regex": {
@ -2987,7 +3003,7 @@
"sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4", "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4",
"sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058" "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"
], ],
"markers": "python_version >= '3.6'", "markers": "python_full_version >= '3.6.0'",
"version": "==20.17.1" "version": "==20.17.1"
}, },
"watchdog": { "watchdog": {

View File

@ -16,6 +16,8 @@ The API provides 7 main endpoints:
- `/api/tags/`: Full CRUD support. - `/api/tags/`: Full CRUD support.
- `/api/mail_accounts/`: Full CRUD support. - `/api/mail_accounts/`: Full CRUD support.
- `/api/mail_rules/`: Full CRUD support. - `/api/mail_rules/`: Full CRUD support.
- `/api/users/`: Full CRUD support.
- `/api/groups/`: Full CRUD support.
All of these endpoints except for the logging endpoint allow you to All of these endpoints except for the logging endpoint allow you to
fetch, edit and delete individual objects by appending their primary key fetch, edit and delete individual objects by appending their primary key
@ -254,6 +256,7 @@ The endpoint supports the following optional form fields:
- `document_type`: Similar to correspondent. - `document_type`: Similar to correspondent.
- `tags`: Similar to correspondent. Specify this multiple times to - `tags`: Similar to correspondent. Specify this multiple times to
have multiple tags added to the document. have multiple tags added to the document.
- `owner`: An optional user ID to set as the owner.
The endpoint will immediately return "OK" if the document consumption The endpoint will immediately return "OK" if the document consumption
process was started successfully. No additional status information about process was started successfully. No additional status information about

View File

@ -202,6 +202,39 @@ configured via `PAPERLESS_EMAIL_TASK_CRON` (see [software tweaks](/configuration
You can also submit a document using the REST API, see [POSTing documents](/api#file-uploads) You can also submit a document using the REST API, see [POSTing documents](/api#file-uploads)
for details. for details.
## Permissions
As of version 1.13.0 Paperless-ngx added core support for user / group permissions. Permissions is
based around an object 'owner' and 'view' and 'edit' permissions can be granted to other users
or groups.
Permissions uses the built-in user model of the backend framework, Django.
!!! note
After migration to version 1.13.0 all existing documents, tags etc. will have no explicit owner
set which means they will be visible / editable by all users. Once an object has an owner set,
only the owner can explicitly grant / revoke permissions.
!!! note
When first migrating to permissions it is recommended to user a 'superuser' account (which
would usually have been setup during installation) to ensure you have full permissions.
Note that superusers have access to all objects.
Permissions can be set using the new "Permissions" tab when editing documents, or bulk-applied
in the UI by selecting documents and choosing the "Permissions" button. Owner can also optionally
be set for documents uploaded via the API. Documents consumed via the consumption dir currently
do not have an owner set.
### Users and Groups
Paperless-ngx versions after 1.13.0 allow creating and editing users and groups via the 'frontend' UI.
These can be found under Settings > Users & Groups, assuming the user has access. If a user is designated
as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
## Best practices {#basic-searching} ## Best practices {#basic-searching}
Paperless offers a couple tools that help you organize your document Paperless offers a couple tools that help you organize your document

View File

@ -0,0 +1,68 @@
describe('settings', () => {
beforeEach(() => {
// also uses global fixtures from cypress/support/e2e.ts
// mock restricted permissions
cy.intercept('http://localhost:8000/api/ui_settings/', {
fixture: 'ui_settings/settings_restricted.json',
})
})
it('should not allow user to edit settings', () => {
cy.visit('/dashboard')
cy.contains('Settings').should('not.exist')
cy.visit('/settings').wait(2000)
cy.contains("You don't have permissions to do that").should('exist')
})
it('should not allow user to view documents', () => {
cy.visit('/dashboard')
cy.contains('Documents').should('not.exist')
cy.visit('/documents').wait(2000)
cy.contains("You don't have permissions to do that").should('exist')
cy.visit('/documents/1').wait(2000)
cy.contains("You don't have permissions to do that").should('exist')
})
it('should not allow user to view correspondents', () => {
cy.visit('/dashboard')
cy.contains('Correspondents').should('not.exist')
cy.visit('/correspondents').wait(2000)
cy.contains("You don't have permissions to do that").should('exist')
})
it('should not allow user to view tags', () => {
cy.visit('/dashboard')
cy.contains('Tags').should('not.exist')
cy.visit('/tags').wait(2000)
cy.contains("You don't have permissions to do that").should('exist')
})
it('should not allow user to view document types', () => {
cy.visit('/dashboard')
cy.contains('Document Types').should('not.exist')
cy.visit('/documenttypes').wait(2000)
cy.contains("You don't have permissions to do that").should('exist')
})
it('should not allow user to view storage paths', () => {
cy.visit('/dashboard')
cy.contains('Storage Paths').should('not.exist')
cy.visit('/storagepaths').wait(2000)
cy.contains("You don't have permissions to do that").should('exist')
})
it('should not allow user to view logs', () => {
cy.visit('/dashboard')
cy.contains('Logs').should('not.exist')
cy.visit('/logs').wait(2000)
cy.contains("You don't have permissions to do that").should('exist')
})
it('should not allow user to view tasks', () => {
cy.visit('/dashboard')
cy.contains('Tasks').should('not.exist')
cy.visit('/tasks').wait(2000)
cy.contains("You don't have permissions to do that").should('exist')
})
})

View File

@ -6,8 +6,8 @@
"user": { "user": {
"id": 1, "id": 1,
"username": "user2", "username": "user2",
"firstname": "", "first_name": "",
"lastname": "" "last_name": ""
} }
}, },
{ {
@ -17,8 +17,8 @@
"user": { "user": {
"id": 2, "id": 2,
"username": "user1", "username": "user1",
"firstname": "", "first_name": "",
"lastname": "" "last_name": ""
} }
}, },
{ {
@ -28,8 +28,8 @@
"user": { "user": {
"id": 2, "id": 2,
"username": "user33", "username": "user33",
"firstname": "", "first_name": "",
"lastname": "" "last_name": ""
} }
}, },
{ {
@ -39,8 +39,8 @@
"user": { "user": {
"id": 3, "id": 3,
"username": "admin", "username": "admin",
"firstname": "", "first_name": "",
"lastname": "" "last_name": ""
} }
} }
] ]

View File

@ -14,11 +14,14 @@
4 4
], ],
"created": "2022-03-22T07:24:18Z", "created": "2022-03-22T07:24:18Z",
"created_date": "2022-03-22",
"modified": "2022-03-22T07:24:23.264859Z", "modified": "2022-03-22T07:24:23.264859Z",
"added": "2022-03-22T07:24:22.922631Z", "added": "2022-03-22T07:24:22.922631Z",
"archive_serial_number": null, "archive_serial_number": null,
"original_file_name": "2022-03-22 no latin title.pdf", "original_file_name": "2022-03-22 no latin title.pdf",
"archived_file_name": "2022-03-22 no latin title.pdf" "archived_file_name": "2022-03-22 no latin title.pdf",
"owner": null,
"permissions": []
}, },
{ {
"id": 2, "id": 2,
@ -29,11 +32,14 @@
"content": "Test document PDF", "content": "Test document PDF",
"tags": [], "tags": [],
"created": "2022-03-23T07:24:18Z", "created": "2022-03-23T07:24:18Z",
"created_date": "2022-03-23",
"modified": "2022-03-23T07:24:23.264859Z", "modified": "2022-03-23T07:24:23.264859Z",
"added": "2022-03-23T07:24:22.922631Z", "added": "2022-03-23T07:24:22.922631Z",
"archive_serial_number": 12345, "archive_serial_number": 12345,
"original_file_name": "2022-03-23 lorem ipsum dolor sit amet.pdf", "original_file_name": "2022-03-23 lorem ipsum dolor sit amet.pdf",
"archived_file_name": "2022-03-23 llorem ipsum dolor sit amet.pdf" "archived_file_name": "2022-03-23 llorem ipsum dolor sit amet.pdf",
"owner": null,
"permissions": []
}, },
{ {
"id": 3, "id": 3,
@ -46,11 +52,14 @@
2 2
], ],
"created": "2022-03-24T07:24:18Z", "created": "2022-03-24T07:24:18Z",
"created_date": "2022-03-24",
"modified": "2022-03-24T07:24:23.264859Z", "modified": "2022-03-24T07:24:23.264859Z",
"added": "2022-03-24T07:24:22.922631Z", "added": "2022-03-24T07:24:22.922631Z",
"archive_serial_number": null, "archive_serial_number": null,
"original_file_name": "2022-03-24 dolor.pdf", "original_file_name": "2022-03-24 dolor.pdf",
"archived_file_name": "2022-03-24 dolor.pdf" "archived_file_name": "2022-03-24 dolor.pdf",
"owner": null,
"permissions": []
}, },
{ {
"id": 4, "id": 4,
@ -63,11 +72,14 @@
4, 5 4, 5
], ],
"created": "2022-06-01T07:24:18Z", "created": "2022-06-01T07:24:18Z",
"created_date": "2022-06-01",
"modified": "2022-06-01T07:24:23.264859Z", "modified": "2022-06-01T07:24:23.264859Z",
"added": "2022-06-01T07:24:22.922631Z", "added": "2022-06-01T07:24:22.922631Z",
"archive_serial_number": 12347, "archive_serial_number": 12347,
"original_file_name": "2022-06-01 sit amet.pdf", "original_file_name": "2022-06-01 sit amet.pdf",
"archived_file_name": "2022-06-01 sit amet.pdf" "archived_file_name": "2022-06-01 sit amet.pdf",
"owner": null,
"permissions": []
} }
] ]
} }

View File

@ -0,0 +1,119 @@
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": 6,
"name": "Another Group",
"permissions": [
"add_user",
"change_user",
"delete_user",
"view_user",
"add_comment",
"change_comment",
"delete_comment",
"view_comment"
]
},
{
"id": 1,
"name": "First Group",
"permissions": [
"add_group",
"change_group",
"delete_group",
"view_group",
"add_permission",
"change_permission",
"delete_permission",
"view_permission",
"add_token",
"change_token",
"delete_token",
"view_token",
"add_tokenproxy",
"change_tokenproxy",
"delete_tokenproxy",
"view_tokenproxy",
"add_contenttype",
"change_contenttype",
"delete_contenttype",
"view_contenttype",
"add_chordcounter",
"change_chordcounter",
"delete_chordcounter",
"view_chordcounter",
"add_groupresult",
"change_groupresult",
"delete_groupresult",
"view_groupresult",
"add_taskresult",
"change_taskresult",
"delete_taskresult",
"view_taskresult",
"add_failure",
"change_failure",
"delete_failure",
"view_failure",
"add_ormq",
"change_ormq",
"delete_ormq",
"view_ormq",
"add_schedule",
"change_schedule",
"delete_schedule",
"view_schedule",
"add_success",
"change_success",
"delete_success",
"view_success",
"add_task",
"change_task",
"delete_task",
"view_task",
"add_comment",
"change_comment",
"delete_comment",
"view_comment",
"add_correspondent",
"change_correspondent",
"delete_correspondent",
"view_correspondent",
"add_document",
"change_document",
"delete_document",
"view_document",
"add_documenttype",
"change_documenttype",
"delete_documenttype",
"view_documenttype",
"add_frontendsettings",
"change_frontendsettings",
"delete_frontendsettings",
"view_frontendsettings",
"add_log",
"change_log",
"delete_log",
"view_log",
"add_savedview",
"change_savedview",
"delete_savedview",
"view_savedview",
"add_savedviewfilterrule",
"change_savedviewfilterrule",
"delete_savedviewfilterrule",
"view_savedviewfilterrule",
"add_taskattributes",
"change_taskattributes",
"delete_taskattributes",
"view_taskattributes",
"add_session",
"change_session",
"delete_session",
"view_session"
]
}
]
}

View File

@ -1,7 +1,6 @@
{ {
"user_id": 1, "user_id": 1,
"username": "admin", "username": "admin",
"display_name": "Admin",
"settings": { "settings": {
"language": "", "language": "",
"bulk_edit": { "bulk_edit": {
@ -30,5 +29,131 @@
"consumer_failed": true, "consumer_failed": true,
"consumer_suppress_on_dashboard": true "consumer_suppress_on_dashboard": true
} }
} },
"permissions": [
"add_logentry",
"change_logentry",
"delete_logentry",
"view_logentry",
"add_group",
"change_group",
"delete_group",
"view_group",
"add_permission",
"change_permission",
"delete_permission",
"view_permission",
"add_user",
"change_user",
"delete_user",
"view_user",
"add_token",
"change_token",
"delete_token",
"view_token",
"add_tokenproxy",
"change_tokenproxy",
"delete_tokenproxy",
"view_tokenproxy",
"add_contenttype",
"change_contenttype",
"delete_contenttype",
"view_contenttype",
"add_chordcounter",
"change_chordcounter",
"delete_chordcounter",
"view_chordcounter",
"add_groupresult",
"change_groupresult",
"delete_groupresult",
"view_groupresult",
"add_taskresult",
"change_taskresult",
"delete_taskresult",
"view_taskresult",
"add_failure",
"change_failure",
"delete_failure",
"view_failure",
"add_ormq",
"change_ormq",
"delete_ormq",
"view_ormq",
"add_schedule",
"change_schedule",
"delete_schedule",
"view_schedule",
"add_success",
"change_success",
"delete_success",
"view_success",
"add_task",
"change_task",
"delete_task",
"view_task",
"add_comment",
"change_comment",
"delete_comment",
"view_comment",
"add_correspondent",
"change_correspondent",
"delete_correspondent",
"view_correspondent",
"add_document",
"change_document",
"delete_document",
"view_document",
"add_documenttype",
"change_documenttype",
"delete_documenttype",
"view_documenttype",
"add_frontendsettings",
"change_frontendsettings",
"delete_frontendsettings",
"view_frontendsettings",
"add_log",
"change_log",
"delete_log",
"view_log",
"add_paperlesstask",
"change_paperlesstask",
"delete_paperlesstask",
"view_paperlesstask",
"add_savedview",
"change_savedview",
"delete_savedview",
"view_savedview",
"add_savedviewfilterrule",
"change_savedviewfilterrule",
"delete_savedviewfilterrule",
"view_savedviewfilterrule",
"add_storagepath",
"change_storagepath",
"delete_storagepath",
"view_storagepath",
"add_tag",
"change_tag",
"delete_tag",
"view_tag",
"add_taskattributes",
"change_taskattributes",
"delete_taskattributes",
"view_taskattributes",
"add_uisettings",
"change_uisettings",
"delete_uisettings",
"view_uisettings",
"add_mailaccount",
"change_mailaccount",
"delete_mailaccount",
"view_mailaccount",
"add_mailrule",
"change_mailrule",
"delete_mailrule",
"view_mailrule",
"add_session",
"change_session",
"delete_session",
"view_session"
]
} }

View File

@ -0,0 +1,84 @@
{
"user_id": 1,
"username": "admin",
"settings": {
"language": "",
"bulk_edit": {
"confirmation_dialogs": true,
"apply_on_close": false
},
"documentListSize": 50,
"dark_mode": {
"use_system": true,
"enabled": "false",
"thumb_inverted": "true"
},
"theme": {
"color": "#b198e5"
},
"document_details": {
"native_pdf_viewer": false
},
"date_display": {
"date_locale": "",
"date_format": "mediumDate"
},
"notifications": {
"consumer_new_documents": true,
"consumer_success": true,
"consumer_failed": true,
"consumer_suppress_on_dashboard": true
}
},
"permissions": [
"add_token",
"change_token",
"delete_token",
"view_token",
"add_tokenproxy",
"change_tokenproxy",
"delete_tokenproxy",
"view_tokenproxy",
"add_contenttype",
"change_contenttype",
"delete_contenttype",
"view_contenttype",
"add_chordcounter",
"change_chordcounter",
"delete_chordcounter",
"view_chordcounter",
"add_groupresult",
"change_groupresult",
"delete_groupresult",
"view_groupresult",
"add_failure",
"change_failure",
"delete_failure",
"view_failure",
"add_ormq",
"change_ormq",
"delete_ormq",
"view_ormq",
"add_schedule",
"change_schedule",
"delete_schedule",
"view_schedule",
"add_success",
"change_success",
"delete_success",
"view_success",
"add_task",
"change_task",
"delete_task",
"view_task",
"add_comment",
"add_frontendsettings",
"change_frontendsettings",
"delete_frontendsettings",
"view_frontendsettings",
"add_session",
"change_session",
"delete_session",
"view_session"
]
}

View File

@ -0,0 +1,459 @@
{
"count": 4,
"next": null,
"previous": null,
"results": [
{
"id": 3,
"username": "admin",
"password": "**********",
"first_name": "",
"last_name": "",
"date_joined": "2022-02-14T23:11:09.103293Z",
"is_staff": true,
"is_active": true,
"is_superuser": true,
"groups": [],
"user_permissions": [],
"inherited_permissions": [
"auth.delete_permission",
"paperless_mail.change_mailrule",
"django_celery_results.add_taskresult",
"documents.view_taskattributes",
"documents.view_paperlesstask",
"django_q.add_success",
"documents.view_uisettings",
"auth.change_user",
"admin.delete_logentry",
"django_celery_results.change_taskresult",
"django_q.change_schedule",
"django_celery_results.delete_taskresult",
"paperless_mail.add_mailaccount",
"auth.change_group",
"documents.add_comment",
"paperless_mail.delete_mailaccount",
"authtoken.delete_tokenproxy",
"guardian.delete_groupobjectpermission",
"contenttypes.delete_contenttype",
"documents.change_correspondent",
"authtoken.delete_token",
"documents.delete_documenttype",
"django_q.change_ormq",
"documents.change_savedviewfilterrule",
"auth.delete_group",
"documents.add_documenttype",
"django_q.change_success",
"documents.delete_tag",
"documents.change_comment",
"django_q.delete_task",
"documents.add_savedviewfilterrule",
"django_q.view_task",
"paperless_mail.add_mailrule",
"paperless_mail.view_mailaccount",
"documents.add_frontendsettings",
"sessions.change_session",
"documents.view_savedview",
"authtoken.add_tokenproxy",
"documents.change_tag",
"documents.view_document",
"documents.add_savedview",
"auth.delete_user",
"documents.view_log",
"documents.view_comment",
"guardian.change_groupobjectpermission",
"sessions.delete_session",
"django_q.change_failure",
"guardian.change_userobjectpermission",
"documents.change_storagepath",
"documents.delete_document",
"documents.delete_taskattributes",
"django_celery_results.change_groupresult",
"django_q.add_ormq",
"guardian.view_groupobjectpermission",
"admin.change_logentry",
"django_q.delete_schedule",
"documents.delete_paperlesstask",
"django_q.view_ormq",
"documents.change_paperlesstask",
"guardian.delete_userobjectpermission",
"auth.view_permission",
"auth.view_user",
"django_q.add_schedule",
"authtoken.change_token",
"guardian.add_groupobjectpermission",
"documents.view_documenttype",
"documents.change_log",
"paperless_mail.delete_mailrule",
"auth.view_group",
"authtoken.view_token",
"admin.view_logentry",
"django_celery_results.view_chordcounter",
"django_celery_results.view_groupresult",
"documents.view_storagepath",
"documents.add_storagepath",
"django_celery_results.add_groupresult",
"documents.view_tag",
"guardian.view_userobjectpermission",
"documents.delete_correspondent",
"documents.add_tag",
"documents.delete_savedviewfilterrule",
"documents.add_correspondent",
"authtoken.view_tokenproxy",
"documents.delete_frontendsettings",
"django_celery_results.delete_chordcounter",
"django_q.change_task",
"documents.add_taskattributes",
"documents.delete_storagepath",
"sessions.add_session",
"documents.add_uisettings",
"documents.change_taskattributes",
"documents.delete_uisettings",
"django_q.delete_ormq",
"auth.change_permission",
"documents.view_savedviewfilterrule",
"documents.change_frontendsettings",
"documents.change_documenttype",
"documents.view_correspondent",
"auth.add_user",
"paperless_mail.change_mailaccount",
"documents.add_paperlesstask",
"django_q.view_success",
"django_celery_results.delete_groupresult",
"documents.delete_savedview",
"authtoken.change_tokenproxy",
"documents.view_frontendsettings",
"authtoken.add_token",
"django_celery_results.add_chordcounter",
"contenttypes.change_contenttype",
"admin.add_logentry",
"django_q.delete_failure",
"documents.change_uisettings",
"django_q.view_failure",
"documents.add_log",
"documents.change_savedview",
"paperless_mail.view_mailrule",
"django_q.view_schedule",
"documents.change_document",
"django_celery_results.change_chordcounter",
"documents.add_document",
"django_celery_results.view_taskresult",
"contenttypes.add_contenttype",
"django_q.delete_success",
"documents.delete_comment",
"django_q.add_failure",
"guardian.add_userobjectpermission",
"sessions.view_session",
"contenttypes.view_contenttype",
"auth.add_permission",
"documents.delete_log",
"django_q.add_task",
"auth.add_group"
]
},
{
"id": 15,
"username": "test",
"password": "**********",
"first_name": "",
"last_name": "",
"date_joined": "2022-11-23T08:30:54Z",
"is_staff": true,
"is_active": true,
"is_superuser": false,
"groups": [
1
],
"user_permissions": [
"add_group",
"change_group",
"delete_group",
"view_group",
"add_permission",
"change_permission",
"delete_permission",
"view_permission",
"add_token",
"change_token",
"delete_token",
"view_token",
"add_tokenproxy",
"change_tokenproxy",
"delete_tokenproxy",
"view_tokenproxy",
"add_contenttype",
"change_contenttype",
"delete_contenttype",
"view_contenttype",
"add_chordcounter",
"change_chordcounter",
"delete_chordcounter",
"view_chordcounter",
"add_groupresult",
"change_groupresult",
"delete_groupresult",
"view_groupresult",
"add_taskresult",
"change_taskresult",
"delete_taskresult",
"view_taskresult",
"add_failure",
"change_failure",
"delete_failure",
"view_failure",
"add_ormq",
"change_ormq",
"delete_ormq",
"view_ormq",
"add_schedule",
"change_schedule",
"delete_schedule",
"view_schedule",
"add_success",
"change_success",
"delete_success",
"view_success",
"add_task",
"change_task",
"delete_task",
"view_task",
"add_comment",
"change_comment",
"delete_comment",
"view_comment",
"add_frontendsettings",
"change_frontendsettings",
"delete_frontendsettings",
"view_frontendsettings",
"add_log",
"change_log",
"delete_log",
"view_log",
"add_savedviewfilterrule",
"change_savedviewfilterrule",
"delete_savedviewfilterrule",
"view_savedviewfilterrule",
"add_taskattributes",
"change_taskattributes",
"delete_taskattributes",
"view_taskattributes",
"add_session",
"change_session",
"delete_session",
"view_session"
],
"inherited_permissions": [
"auth.delete_permission",
"django_celery_results.add_taskresult",
"documents.view_taskattributes",
"django_q.add_ormq",
"django_q.add_success",
"django_q.delete_schedule",
"django_q.view_ormq",
"auth.view_permission",
"django_q.add_schedule",
"django_celery_results.change_taskresult",
"django_q.change_schedule",
"django_celery_results.delete_taskresult",
"authtoken.change_token",
"auth.change_group",
"documents.add_comment",
"authtoken.delete_tokenproxy",
"documents.view_documenttype",
"contenttypes.delete_contenttype",
"documents.change_correspondent",
"authtoken.delete_token",
"documents.change_log",
"auth.view_group",
"authtoken.view_token",
"django_celery_results.view_chordcounter",
"django_celery_results.view_groupresult",
"documents.delete_documenttype",
"django_q.change_ormq",
"documents.change_savedviewfilterrule",
"django_celery_results.add_groupresult",
"auth.delete_group",
"documents.add_documenttype",
"django_q.change_success",
"auth.add_permission",
"documents.delete_correspondent",
"documents.delete_savedviewfilterrule",
"documents.add_correspondent",
"authtoken.view_tokenproxy",
"documents.delete_frontendsettings",
"django_celery_results.delete_chordcounter",
"documents.add_taskattributes",
"django_q.change_task",
"sessions.add_session",
"documents.change_taskattributes",
"documents.change_comment",
"django_q.delete_task",
"django_q.delete_ormq",
"auth.change_permission",
"documents.add_savedviewfilterrule",
"django_q.view_task",
"documents.view_savedviewfilterrule",
"documents.change_frontendsettings",
"documents.change_documenttype",
"documents.view_correspondent",
"django_q.view_success",
"documents.add_frontendsettings",
"django_celery_results.delete_groupresult",
"documents.delete_savedview",
"authtoken.change_tokenproxy",
"documents.view_frontendsettings",
"authtoken.add_token",
"sessions.change_session",
"django_celery_results.add_chordcounter",
"documents.view_savedview",
"contenttypes.change_contenttype",
"django_q.delete_failure",
"authtoken.add_tokenproxy",
"documents.view_document",
"documents.add_savedview",
"django_q.view_failure",
"documents.view_comment",
"documents.view_log",
"documents.add_log",
"documents.change_savedview",
"django_q.view_schedule",
"documents.change_document",
"django_celery_results.change_chordcounter",
"documents.add_document",
"sessions.delete_session",
"django_q.change_failure",
"django_celery_results.view_taskresult",
"contenttypes.add_contenttype",
"django_q.delete_success",
"documents.delete_comment",
"django_q.add_failure",
"sessions.view_session",
"contenttypes.view_contenttype",
"documents.delete_taskattributes",
"documents.delete_document",
"documents.delete_log",
"django_q.add_task",
"django_celery_results.change_groupresult",
"auth.add_group"
]
},
{
"id": 6,
"username": "testuser",
"password": "**********",
"first_name": "",
"last_name": "",
"date_joined": "2022-11-16T04:14:20.484914Z",
"is_staff": false,
"is_active": true,
"is_superuser": false,
"groups": [
1,
6
],
"user_permissions": [
"add_logentry",
"change_logentry",
"delete_logentry",
"view_logentry"
],
"inherited_permissions": [
"auth.delete_permission",
"django_celery_results.add_taskresult",
"documents.view_taskattributes",
"django_q.add_ormq",
"django_q.add_success",
"django_q.delete_schedule",
"django_q.view_ormq",
"auth.change_user",
"auth.view_permission",
"auth.view_user",
"django_q.add_schedule",
"django_celery_results.change_taskresult",
"django_q.change_schedule",
"django_celery_results.delete_taskresult",
"authtoken.change_token",
"auth.change_group",
"documents.add_comment",
"authtoken.delete_tokenproxy",
"documents.view_documenttype",
"contenttypes.delete_contenttype",
"documents.change_correspondent",
"authtoken.delete_token",
"documents.change_log",
"auth.view_group",
"authtoken.view_token",
"django_celery_results.view_chordcounter",
"django_celery_results.view_groupresult",
"documents.delete_documenttype",
"django_q.change_ormq",
"documents.change_savedviewfilterrule",
"django_celery_results.add_groupresult",
"auth.delete_group",
"documents.add_documenttype",
"django_q.change_success",
"auth.add_permission",
"documents.delete_correspondent",
"documents.delete_savedviewfilterrule",
"documents.add_correspondent",
"authtoken.view_tokenproxy",
"documents.delete_frontendsettings",
"django_celery_results.delete_chordcounter",
"documents.add_taskattributes",
"django_q.change_task",
"sessions.add_session",
"documents.change_taskattributes",
"documents.change_comment",
"django_q.delete_task",
"django_q.delete_ormq",
"auth.change_permission",
"documents.add_savedviewfilterrule",
"django_q.view_task",
"documents.view_savedviewfilterrule",
"documents.change_frontendsettings",
"documents.change_documenttype",
"documents.view_correspondent",
"auth.add_user",
"django_q.view_success",
"documents.add_frontendsettings",
"django_celery_results.delete_groupresult",
"documents.delete_savedview",
"authtoken.change_tokenproxy",
"documents.view_frontendsettings",
"authtoken.add_token",
"sessions.change_session",
"django_celery_results.add_chordcounter",
"documents.view_savedview",
"contenttypes.change_contenttype",
"django_q.delete_failure",
"authtoken.add_tokenproxy",
"documents.view_document",
"documents.add_savedview",
"django_q.view_failure",
"documents.view_comment",
"documents.view_log",
"auth.delete_user",
"documents.add_log",
"documents.change_savedview",
"django_q.view_schedule",
"documents.change_document",
"django_celery_results.change_chordcounter",
"documents.add_document",
"sessions.delete_session",
"django_q.change_failure",
"django_celery_results.view_taskresult",
"contenttypes.add_contenttype",
"django_q.delete_success",
"documents.delete_comment",
"django_q.add_failure",
"sessions.view_session",
"contenttypes.view_contenttype",
"documents.delete_taskattributes",
"documents.delete_document",
"documents.delete_log",
"django_q.add_task",
"django_celery_results.change_groupresult",
"auth.add_group"
]
}
]
}

View File

@ -5,6 +5,14 @@ beforeEach(() => {
fixture: 'ui_settings/settings.json', fixture: 'ui_settings/settings.json',
}).as('ui-settings') }).as('ui-settings')
cy.intercept('http://localhost:8000/api/users/*', {
fixture: 'users/users.json',
})
cy.intercept('http://localhost:8000/api/groups/*', {
fixture: 'groups/groups.json',
})
cy.intercept('http://localhost:8000/api/remote_version/', { cy.intercept('http://localhost:8000/api/remote_version/', {
fixture: 'remote_version/remote_version.json', fixture: 'remote_version/remote_version.json',
}) })

File diff suppressed because it is too large Load Diff

View File

@ -14,8 +14,13 @@ import { DocumentAsnComponent } from './components/document-asn/document-asn.com
import { DirtyFormGuard } from './guards/dirty-form.guard' import { DirtyFormGuard } from './guards/dirty-form.guard'
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
import { TasksComponent } from './components/manage/tasks/tasks.component' import { TasksComponent } from './components/manage/tasks/tasks.component'
import { PermissionsGuard } from './guards/permissions.guard'
import { DirtyDocGuard } from './guards/dirty-doc.guard' import { DirtyDocGuard } from './guards/dirty-doc.guard'
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
import {
PermissionAction,
PermissionType,
} from './services/permissions.service'
const routes: Routes = [ const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
@ -29,23 +34,137 @@ const routes: Routes = [
path: 'documents', path: 'documents',
component: DocumentListComponent, component: DocumentListComponent,
canDeactivate: [DirtySavedViewGuard], canDeactivate: [DirtySavedViewGuard],
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Document,
},
},
}, },
{ {
path: 'view/:id', path: 'view/:id',
component: DocumentListComponent, component: DocumentListComponent,
canDeactivate: [DirtySavedViewGuard], canDeactivate: [DirtySavedViewGuard],
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.SavedView,
},
},
},
{
path: 'documents/:id',
component: DocumentDetailComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Document,
},
},
},
{
path: 'asn/:id',
component: DocumentAsnComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Document,
},
},
},
{
path: 'tags',
component: TagListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Tag,
},
},
},
{
path: 'documenttypes',
component: DocumentTypeListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
},
},
{
path: 'correspondents',
component: CorrespondentListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Correspondent,
},
},
},
{
path: 'storagepaths',
component: StoragePathListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.StoragePath,
},
},
},
{
path: 'logs',
component: LogsComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Admin,
},
},
}, },
{ path: 'documents/:id', component: DocumentDetailComponent },
{ path: 'asn/:id', component: DocumentAsnComponent },
{ path: 'tags', component: TagListComponent },
{ path: 'documenttypes', component: DocumentTypeListComponent },
{ path: 'correspondents', component: CorrespondentListComponent },
{ path: 'storagepaths', component: StoragePathListComponent },
{ path: 'logs', component: LogsComponent },
{ {
path: 'settings', path: 'settings',
component: SettingsComponent, component: SettingsComponent,
canDeactivate: [DirtyFormGuard], canDeactivate: [DirtyFormGuard],
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.UISettings,
},
},
},
{
path: 'tasks',
component: TasksComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.PaperlessTask,
},
},
},
{
path: 'settings/:section',
component: SettingsComponent,
canDeactivate: [DirtyFormGuard],
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.UISettings,
},
},
}, },
{ {
path: 'settings/:section', path: 'settings/:section',

View File

@ -9,6 +9,11 @@ import { NgxFileDropEntry } from 'ngx-file-drop'
import { UploadDocumentsService } from './services/upload-documents.service' import { UploadDocumentsService } from './services/upload-documents.service'
import { TasksService } from './services/tasks.service' import { TasksService } from './services/tasks.service'
import { TourService } from 'ngx-ui-tour-ng-bootstrap' import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from './services/permissions.service'
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -32,7 +37,8 @@ export class AppComponent implements OnInit, OnDestroy {
private uploadDocumentsService: UploadDocumentsService, private uploadDocumentsService: UploadDocumentsService,
private tasksService: TasksService, private tasksService: TasksService,
public tourService: TourService, public tourService: TourService,
private renderer: Renderer2 private renderer: Renderer2,
private permissionsService: PermissionsService
) { ) {
let anyWindow = window as any let anyWindow = window as any
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js' anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'
@ -74,15 +80,28 @@ export class AppComponent implements OnInit, OnDestroy {
if ( if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS) this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
) { ) {
this.toastService.show({ if (
title: $localize`Document added`, this.permissionsService.currentUserCan(
delay: 10000, PermissionAction.View,
content: $localize`Document ${status.filename} was added to paperless.`, PermissionType.Document
actionName: $localize`Open document`, )
action: () => { ) {
this.router.navigate(['documents', status.documentId]) this.toastService.show({
}, title: $localize`Document added`,
}) delay: 10000,
content: $localize`Document ${status.filename} was added to paperless.`,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', status.documentId])
},
})
} else {
this.toastService.show({
title: $localize`Document added`,
delay: 10000,
content: $localize`Document ${status.filename} was added to paperless.`,
})
}
} }
}) })
@ -225,7 +244,13 @@ export class AppComponent implements OnInit, OnDestroy {
} }
public get dragDropEnabled(): boolean { public get dragDropEnabled(): boolean {
return !this.router.url.includes('dashboard') return (
!this.router.url.includes('dashboard') &&
this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.Document
)
)
} }
public fileOver() { public fileOver() {

View File

@ -42,6 +42,7 @@ import { CheckComponent } from './components/common/input/check/check.component'
import { PasswordComponent } from './components/common/input/password/password.component' import { PasswordComponent } from './components/common/input/password/password.component'
import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component' import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component'
import { TagsComponent } from './components/common/input/tags/tags.component' import { TagsComponent } from './components/common/input/tags/tags.component'
import { IfPermissionsDirective } from './directives/if-permissions.directive'
import { SortableDirective } from './directives/sortable.directive' import { SortableDirective } from './directives/sortable.directive'
import { CookieService } from 'ngx-cookie-service' import { CookieService } from 'ngx-cookie-service'
import { CsrfInterceptor } from './interceptors/csrf.interceptor' import { CsrfInterceptor } from './interceptors/csrf.interceptor'
@ -70,6 +71,7 @@ import { ColorSliderModule } from 'ngx-color/slider'
import { ColorComponent } from './components/common/input/color/color.component' import { ColorComponent } from './components/common/input/color/color.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component' import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DocumentCommentsComponent } from './components/document-comments/document-comments.component' import { DocumentCommentsComponent } from './components/document-comments/document-comments.component'
import { PermissionsGuard } from './guards/permissions.guard'
import { DirtyDocGuard } from './guards/dirty-doc.guard' import { DirtyDocGuard } from './guards/dirty-doc.guard'
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
@ -77,8 +79,15 @@ import { StoragePathEditDialogComponent } from './components/common/edit-dialog/
import { SettingsService } from './services/settings.service' import { SettingsService } from './services/settings.service'
import { TasksComponent } from './components/manage/tasks/tasks.component' import { TasksComponent } from './components/manage/tasks/tasks.component'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' 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'
import { PermissionsSelectComponent } from './components/common/permissions-select/permissions-select.component'
import { MailAccountEditDialogComponent } from './components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component' import { MailAccountEditDialogComponent } from './components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
import { MailRuleEditDialogComponent } from './components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component' import { MailRuleEditDialogComponent } from './components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
import { PermissionsUserComponent } from './components/common/input/permissions/permissions-user/permissions-user.component'
import { PermissionsGroupComponent } from './components/common/input/permissions/permissions-group/permissions-group.component'
import { IfOwnerDirective } from './directives/if-owner.directive'
import { IfObjectPermissionsDirective } from './directives/if-object-permissions.directive'
import localeAr from '@angular/common/locales/ar' import localeAr from '@angular/common/locales/ar'
import localeBe from '@angular/common/locales/be' import localeBe from '@angular/common/locales/be'
@ -100,6 +109,8 @@ import localeSr from '@angular/common/locales/sr'
import localeSv from '@angular/common/locales/sv' import localeSv from '@angular/common/locales/sv'
import localeTr from '@angular/common/locales/tr' import localeTr from '@angular/common/locales/tr'
import localeZh from '@angular/common/locales/zh' import localeZh from '@angular/common/locales/zh'
import { PermissionsDialogComponent } from './components/common/permissions-dialog/permissions-dialog.component'
import { PermissionsFormComponent } from './components/common/input/permissions/permissions-form/permissions-form.component'
registerLocaleData(localeAr) registerLocaleData(localeAr)
registerLocaleData(localeBe) registerLocaleData(localeBe)
@ -165,6 +176,7 @@ function initializeApp(settings: SettingsService) {
PasswordComponent, PasswordComponent,
SaveViewConfigDialogComponent, SaveViewConfigDialogComponent,
TagsComponent, TagsComponent,
IfPermissionsDirective,
SortableDirective, SortableDirective,
SavedViewWidgetComponent, SavedViewWidgetComponent,
StatisticsWidgetComponent, StatisticsWidgetComponent,
@ -186,8 +198,17 @@ function initializeApp(settings: SettingsService) {
DocumentAsnComponent, DocumentAsnComponent,
DocumentCommentsComponent, DocumentCommentsComponent,
TasksComponent, TasksComponent,
UserEditDialogComponent,
GroupEditDialogComponent,
PermissionsSelectComponent,
MailAccountEditDialogComponent, MailAccountEditDialogComponent,
MailRuleEditDialogComponent, MailRuleEditDialogComponent,
PermissionsUserComponent,
PermissionsGroupComponent,
IfOwnerDirective,
IfObjectPermissionsDirective,
PermissionsDialogComponent,
PermissionsFormComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
@ -225,6 +246,7 @@ function initializeApp(settings: SettingsService) {
DocumentTitlePipe, DocumentTitlePipe,
{ provide: NgbDateAdapter, useClass: ISODateAdapter }, { provide: NgbDateAdapter, useClass: ISODateAdapter },
{ provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter }, { provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter },
PermissionsGuard,
DirtyDocGuard, DirtyDocGuard,
DirtySavedViewGuard, DirtySavedViewGuard,
], ],

View File

@ -10,7 +10,7 @@
</svg> </svg>
<span class="ms-2" [class.visually-hidden]="slimSidebarEnabled" i18n="app title">Paperless-ngx</span> <span class="ms-2" [class.visually-hidden]="slimSidebarEnabled" i18n="app title">Paperless-ngx</span>
</a> </a>
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"> <div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<form (ngSubmit)="search()" class="form-inline flex-grow-1"> <form (ngSubmit)="search()" class="form-inline flex-grow-1">
<svg width="1em" height="1em" fill="currentColor"> <svg width="1em" height="1em" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#search"/> <use xlink:href="assets/bootstrap-icons.svg#search"/>
@ -39,7 +39,7 @@
<p class="small mb-0 px-3 text-muted" i18n>Logged in as {{this.settingsService.displayName}}</p> <p class="small mb-0 px-3 text-muted" i18n>Logged in as {{this.settingsService.displayName}}</p>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
</div> </div>
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"> <a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }">
<svg class="sidebaricon me-2" fill="currentColor"> <svg class="sidebaricon me-2" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#gear"/> <use xlink:href="assets/bootstrap-icons.svg#gear"/>
</svg><ng-container i18n>Settings</ng-container> </svg><ng-container i18n>Settings</ng-container>
@ -72,7 +72,7 @@
</svg><span>&nbsp;<ng-container i18n>Dashboard</ng-container></span> </svg><span>&nbsp;<ng-container i18n>Dashboard</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#files"/> <use xlink:href="assets/bootstrap-icons.svg#files"/>
@ -80,79 +80,82 @@
</a> </a>
</li> </li>
</ul> </ul>
<div *appIfPermissions="{ 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'>
<span i18n>Saved views</span>
<div *ngIf="savedViewService.loading" class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#funnel"/>
</svg><span>&nbsp;{{view.name}}</span>
</a>
</li>
</ul>
</div>
<h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.loading || savedViewService.sidebarViews.length > 0'> <div *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<span i18n>Saved views</span> <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'>
<div *ngIf="savedViewService.loading" class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div> <span i18n>Open documents</span>
</h6> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
<li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews"> <li class="nav-item w-100" *ngFor='let d of openDocuments'>
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#funnel"/> <use xlink:href="assets/bootstrap-icons.svg#file-text"/>
</svg><span>&nbsp;{{view.name}}</span> </svg><span>&nbsp;{{d.title | documentTitle}}</span>
</a> <span class="close" (click)="closeDocument(d); $event.preventDefault()">
</li> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16">
</ul> <use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>
<h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'> </span>
<span i18n>Open documents</span> </a>
</h6> </li>
<ul class="nav flex-column mb-2"> <li class="nav-item w-100" *ngIf="openDocuments.length >= 1">
<li class="nav-item w-100" *ngFor='let d of openDocuments'> <a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()" ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <svg class="sidebaricon" fill="currentColor">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
</svg><span>&nbsp;{{d.title | documentTitle}}</span>
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16">
<use xlink:href="assets/bootstrap-icons.svg#x"/> <use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg> </svg><span>&nbsp;<ng-container i18n>Close all</ng-container></span>
</span> </a>
</a> </li>
</li> </ul>
<li class="nav-item w-100" *ngIf="openDocuments.length >= 1"> </div>
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()" ngbPopover="Close all" 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#x"/>
</svg><span>&nbsp;<ng-container i18n>Close all</ng-container></span>
</a>
</li>
</ul>
<h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted"> <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted">
<span i18n>Manage</span> <span i18n>Manage</span>
</h6> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
<li class="nav-item"> <li class="nav-item" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person"/> <use xlink:href="assets/bootstrap-icons.svg#person"/>
</svg><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span> </svg><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" tourAnchor="tour.tags"> <li class="nav-item" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }" tourAnchor="tour.tags">
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#tags"/> <use xlink:href="assets/bootstrap-icons.svg#tags"/>
</svg><span>&nbsp;<ng-container i18n>Tags</ng-container></span> </svg><span>&nbsp;<ng-container i18n>Tags</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#hash"/> <use xlink:href="assets/bootstrap-icons.svg#hash"/>
</svg><span>&nbsp;<ng-container i18n>Document types</ng-container></span> </svg><span>&nbsp;<ng-container i18n>Document types</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#folder"/> <use xlink:href="assets/bootstrap-icons.svg#folder"/>
</svg><span>&nbsp;<ng-container i18n>Storage paths</ng-container></span> </svg><span>&nbsp;<ng-container i18n>Storage paths</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" tourAnchor="tour.file-tasks"> <li class="nav-item" *appIfPermissions="{ 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"> <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> <span *ngIf="tasksService.failedFileTasks.length > 0 && slimSidebarEnabled" class="badge bg-danger position-absolute top-0 end-0">{{tasksService.failedFileTasks.length}}</span>
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
@ -160,14 +163,14 @@
</svg><span>&nbsp;<ng-container i18n>File Tasks<span *ngIf="tasksService.failedFileTasks.length > 0"><span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></span></ng-container></span> </svg><span>&nbsp;<ng-container i18n>File Tasks<span *ngIf="tasksService.failedFileTasks.length > 0"><span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></span></ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#text-left"/> <use xlink:href="assets/bootstrap-icons.svg#text-left"/>
</svg><span>&nbsp;<ng-container i18n>Logs</ng-container></span> </svg><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" tourAnchor="tour.settings"> <li class="nav-item" *appIfPermissions="{ 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"> <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"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#gear"/> <use xlink:href="assets/bootstrap-icons.svg#gear"/>

View File

@ -26,13 +26,17 @@ import { TasksService } from 'src/app/services/tasks.service'
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard' import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
@Component({ @Component({
selector: 'app-app-frame', selector: 'app-app-frame',
templateUrl: './app-frame.component.html', templateUrl: './app-frame.component.html',
styleUrls: ['./app-frame.component.scss'], styleUrls: ['./app-frame.component.scss'],
}) })
export class AppFrameComponent implements OnInit, ComponentCanDeactivate { export class AppFrameComponent
extends ComponentWithPermissions
implements OnInit, ComponentCanDeactivate
{
constructor( constructor(
public router: Router, public router: Router,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
@ -44,7 +48,9 @@ export class AppFrameComponent implements OnInit, ComponentCanDeactivate {
public settingsService: SettingsService, public settingsService: SettingsService,
public tasksService: TasksService, public tasksService: TasksService,
private readonly toastService: ToastService private readonly toastService: ToastService
) {} ) {
super()
}
ngOnInit(): void { ngOnInit(): void {
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) { if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {

View File

@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, Output } from '@angular/core' import { Component, EventEmitter, Input, Output } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { interval, Subject, switchMap, take } from 'rxjs' import { interval, Subject, take } from 'rxjs'
@Component({ @Component({
selector: 'app-confirm-dialog', selector: 'app-confirm-dialog',

View File

@ -5,10 +5,16 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text>
<app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select>
<app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text> <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text>
<app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive" novalidate></app-input-check> <app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive" novalidate></app-input-check>
<div *appIfOwner="object">
<app-permissions-form [users]="users" accordion="true" formControlName="permissions_form"></app-permissions-form>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>

View File

@ -5,6 +5,7 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { UserService } from 'src/app/services/rest/user.service'
@Component({ @Component({
selector: 'app-correspondent-edit-dialog', selector: 'app-correspondent-edit-dialog',
@ -12,8 +13,12 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
styleUrls: ['./correspondent-edit-dialog.component.scss'], styleUrls: ['./correspondent-edit-dialog.component.scss'],
}) })
export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> { export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> {
constructor(service: CorrespondentService, activeModal: NgbActiveModal) { constructor(
super(service, activeModal) service: CorrespondentService,
activeModal: NgbActiveModal,
userService: UserService
) {
super(service, activeModal, userService)
} }
getCreateTitle() { getCreateTitle() {
@ -30,6 +35,7 @@ export class CorrespondentEditDialogComponent extends EditDialogComponent<Paperl
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM), matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
match: new FormControl(''), match: new FormControl(''),
is_insensitive: new FormControl(true), is_insensitive: new FormControl(true),
permissions_form: new FormControl(null),
}) })
} }
} }

View File

@ -6,10 +6,16 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> <div class="col">
<app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text>
<app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text> <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select>
<app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check> <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text>
<app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check>
</div>
<div *appIfOwner="object">
<app-permissions-form [users]="users" accordion="true" formControlName="permissions_form"></app-permissions-form>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -5,6 +5,7 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { UserService } from 'src/app/services/rest/user.service'
@Component({ @Component({
selector: 'app-document-type-edit-dialog', selector: 'app-document-type-edit-dialog',
@ -12,8 +13,12 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
styleUrls: ['./document-type-edit-dialog.component.scss'], styleUrls: ['./document-type-edit-dialog.component.scss'],
}) })
export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> { export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> {
constructor(service: DocumentTypeService, activeModal: NgbActiveModal) { constructor(
super(service, activeModal) service: DocumentTypeService,
activeModal: NgbActiveModal,
userService: UserService
) {
super(service, activeModal, userService)
} }
getCreateTitle() { getCreateTitle() {
@ -30,6 +35,7 @@ export class DocumentTypeEditDialogComponent extends EditDialogComponent<Paperle
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM), matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
match: new FormControl(''), match: new FormControl(''),
is_insensitive: new FormControl(true), is_insensitive: new FormControl(true),
permissions_form: new FormControl(null),
}) })
} }
} }

View File

@ -4,17 +4,25 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model' import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { PaperlessUser } from 'src/app/data/paperless-user'
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service' import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
import { UserService } from 'src/app/services/rest/user.service'
import { PermissionsFormObject } from '../input/permissions/permissions-form/permissions-form.component'
@Directive() @Directive()
export abstract class EditDialogComponent<T extends ObjectWithId> export abstract class EditDialogComponent<
implements OnInit T extends ObjectWithPermissions | ObjectWithId
> implements OnInit
{ {
constructor( constructor(
private service: AbstractPaperlessService<T>, private service: AbstractPaperlessService<T>,
private activeModal: NgbActiveModal private activeModal: NgbActiveModal,
private userService: UserService
) {} ) {}
users: PaperlessUser[]
@Input() @Input()
dialogMode: string = 'create' dialogMode: string = 'create'
@ -36,6 +44,14 @@ export abstract class EditDialogComponent<T extends ObjectWithId>
ngOnInit(): void { ngOnInit(): void {
if (this.object != null) { if (this.object != null) {
if (this.object['permissions']) {
this.object['set_permissions'] = this.object['permissions']
}
this.object['permissions_form'] = {
owner: (this.object as ObjectWithPermissions).owner,
set_permissions: (this.object as ObjectWithPermissions).permissions,
}
this.objectForm.patchValue(this.object) this.objectForm.patchValue(this.object)
} }
@ -43,6 +59,8 @@ export abstract class EditDialogComponent<T extends ObjectWithId>
setTimeout(() => { setTimeout(() => {
this.closeEnabled = true this.closeEnabled = true
}) })
this.userService.listAll().subscribe((r) => (this.users = r.results))
} }
getCreateTitle() { getCreateTitle() {
@ -77,10 +95,16 @@ export abstract class EditDialogComponent<T extends ObjectWithId>
} }
save() { save() {
var newObject = Object.assign( const formValues = Object.assign({}, this.objectForm.value)
Object.assign({}, this.object), const permissionsObject: PermissionsFormObject =
this.objectForm.value this.objectForm.get('permissions_form')?.value
) if (permissionsObject) {
formValues.owner = permissionsObject.owner
formValues.set_permissions = permissionsObject.set_permissions
delete formValues.permissions_form
}
var newObject = Object.assign(Object.assign({}, this.object), formValues)
var serverResponse: Observable<T> var serverResponse: Observable<T>
switch (this.dialogMode) { switch (this.dialogMode) {
case 'create': case 'create':

View File

@ -0,0 +1,19 @@
<form [formGroup]="objectForm" (ngSubmit)="save()">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col">
<app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text>
<app-permissions-select i18n-title title="Permissions" formControlName="permissions" [error]="error?.permissions"></app-permissions-select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
</div>
</form>

View File

@ -0,0 +1,37 @@
import { Component } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { PaperlessGroup } from 'src/app/data/paperless-group'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
@Component({
selector: 'app-group-edit-dialog',
templateUrl: './group-edit-dialog.component.html',
styleUrls: ['./group-edit-dialog.component.scss'],
})
export class GroupEditDialogComponent extends EditDialogComponent<PaperlessGroup> {
constructor(
service: GroupService,
activeModal: NgbActiveModal,
userService: UserService
) {
super(service, activeModal, userService)
}
getCreateTitle() {
return $localize`Create new user group`
}
getEditTitle() {
return $localize`Edit user group`
}
getForm(): FormGroup {
return new FormGroup({
name: new FormControl(''),
permissions: new FormControl(null),
})
}
}

View File

@ -7,6 +7,7 @@ import {
PaperlessMailAccount, PaperlessMailAccount,
} from 'src/app/data/paperless-mail-account' } from 'src/app/data/paperless-mail-account'
import { MailAccountService } from 'src/app/services/rest/mail-account.service' import { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { UserService } from 'src/app/services/rest/user.service'
const IMAP_SECURITY_OPTIONS = [ const IMAP_SECURITY_OPTIONS = [
{ id: IMAPSecurity.None, name: $localize`No encryption` }, { id: IMAPSecurity.None, name: $localize`No encryption` },
@ -20,8 +21,12 @@ const IMAP_SECURITY_OPTIONS = [
styleUrls: ['./mail-account-edit-dialog.component.scss'], styleUrls: ['./mail-account-edit-dialog.component.scss'],
}) })
export class MailAccountEditDialogComponent extends EditDialogComponent<PaperlessMailAccount> { export class MailAccountEditDialogComponent extends EditDialogComponent<PaperlessMailAccount> {
constructor(service: MailAccountService, activeModal: NgbActiveModal) { constructor(
super(service, activeModal) service: MailAccountService,
activeModal: NgbActiveModal,
userService: UserService
) {
super(service, activeModal, userService)
} }
getCreateTitle() { getCreateTitle() {

View File

@ -18,6 +18,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { MailAccountService } from 'src/app/services/rest/mail-account.service' import { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service' import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { UserService } from 'src/app/services/rest/user.service'
const ATTACHMENT_TYPE_OPTIONS = [ const ATTACHMENT_TYPE_OPTIONS = [
{ {
@ -113,9 +114,10 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<PaperlessMa
activeModal: NgbActiveModal, activeModal: NgbActiveModal,
accountService: MailAccountService, accountService: MailAccountService,
correspondentService: CorrespondentService, correspondentService: CorrespondentService,
documentTypeService: DocumentTypeService documentTypeService: DocumentTypeService,
userService: UserService
) { ) {
super(service, activeModal) super(service, activeModal, userService)
accountService accountService
.listAll() .listAll()

View File

@ -16,6 +16,10 @@
<app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text> <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text>
<app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check> <app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check>
<div *appIfOwner="object">
<app-permissions-form [users]="users" accordion="true" formControlName="permissions_form"></app-permissions-form>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>

View File

@ -5,6 +5,7 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service'
@Component({ @Component({
selector: 'app-storage-path-edit-dialog', selector: 'app-storage-path-edit-dialog',
@ -12,8 +13,12 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
styleUrls: ['./storage-path-edit-dialog.component.scss'], styleUrls: ['./storage-path-edit-dialog.component.scss'],
}) })
export class StoragePathEditDialogComponent extends EditDialogComponent<PaperlessStoragePath> { export class StoragePathEditDialogComponent extends EditDialogComponent<PaperlessStoragePath> {
constructor(service: StoragePathService, activeModal: NgbActiveModal) { constructor(
super(service, activeModal) service: StoragePathService,
activeModal: NgbActiveModal,
userService: UserService
) {
super(service, activeModal, userService)
} }
get pathHint() { get pathHint() {
@ -41,6 +46,7 @@ export class StoragePathEditDialogComponent extends EditDialogComponent<Paperles
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM), matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
match: new FormControl(''), match: new FormControl(''),
is_insensitive: new FormControl(true), is_insensitive: new FormControl(true),
permissions_form: new FormControl(null),
}) })
} }
} }

View File

@ -13,6 +13,11 @@
<app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select>
<app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text> <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text>
<app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check> <app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check>
<div *appIfOwner="object">
<app-permissions-form [users]="users" accordion="true" formControlName="permissions_form"></app-permissions-form>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>

View File

@ -6,6 +6,7 @@ import { PaperlessTag } from 'src/app/data/paperless-tag'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { randomColor } from 'src/app/utils/color' import { randomColor } from 'src/app/utils/color'
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { UserService } from 'src/app/services/rest/user.service'
@Component({ @Component({
selector: 'app-tag-edit-dialog', selector: 'app-tag-edit-dialog',
@ -13,8 +14,12 @@ import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
styleUrls: ['./tag-edit-dialog.component.scss'], styleUrls: ['./tag-edit-dialog.component.scss'],
}) })
export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> { export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> {
constructor(service: TagService, activeModal: NgbActiveModal) { constructor(
super(service, activeModal) service: TagService,
activeModal: NgbActiveModal,
userService: UserService
) {
super(service, activeModal, userService)
} }
getCreateTitle() { getCreateTitle() {
@ -33,6 +38,7 @@ export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> {
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM), matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
match: new FormControl(''), match: new FormControl(''),
is_insensitive: new FormControl(true), is_insensitive: new FormControl(true),
permissions_form: new FormControl(null),
}) })
} }
} }

View File

@ -0,0 +1,38 @@
<form [formGroup]="objectForm" (ngSubmit)="save()">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col">
<app-input-text i18n-title title="Username" formControlName="username" [error]="error?.username"></app-input-text>
<app-input-text i18n-title title="Email" formControlName="email" [error]="error?.email"></app-input-text>
<app-input-password i18n-title title="Password" formControlName="password" [error]="error?.password"></app-input-password>
<app-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></app-input-text>
<app-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></app-input-text>
<div class="mb-2">
<div class="form-check form-switch form-check-inline">
<input type="checkbox" class="form-check-input" id="is_active" formControlName="is_active">
<label class="form-check-label" for="is_active" i18n>Active</label>
</div>
<div class="form-check form-switch form-check-inline">
<input type="checkbox" class="form-check-input" id="is_superuser" formControlName="is_superuser" (change)="onToggleSuperUser()">
<label class="form-check-label" for="is_superuser"><ng-container i18n>Superuser</ng-container> <small class="form-text text-muted ms-1" i18n>(Grants all permissions and can view objects)</small></label>
</div>
</div>
<app-input-select i18n-title title="Groups" [items]="groups" multiple="true" formControlName="groups"></app-input-select>
</div>
<div class="col">
<app-permissions-select i18n-title title="Permissions" formControlName="user_permissions" [error]="error?.user_permissions" [inheritedPermissions]="inheritedPermissions"></app-permissions-select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
</div>
</form>

View File

@ -0,0 +1,79 @@
import { Component, OnInit } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { PaperlessGroup } from 'src/app/data/paperless-group'
import { PaperlessUser } from 'src/app/data/paperless-user'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
@Component({
selector: 'app-user-edit-dialog',
templateUrl: './user-edit-dialog.component.html',
styleUrls: ['./user-edit-dialog.component.scss'],
})
export class UserEditDialogComponent
extends EditDialogComponent<PaperlessUser>
implements OnInit
{
groups: PaperlessGroup[]
constructor(
service: UserService,
activeModal: NgbActiveModal,
groupsService: GroupService
) {
super(service, activeModal, service)
groupsService
.listAll()
.pipe(first())
.subscribe((result) => (this.groups = result.results))
}
ngOnInit(): void {
super.ngOnInit()
this.onToggleSuperUser()
}
getCreateTitle() {
return $localize`Create new user account`
}
getEditTitle() {
return $localize`Edit user account`
}
getForm(): FormGroup {
return new FormGroup({
username: new FormControl(''),
email: new FormControl(''),
password: new FormControl(null),
first_name: new FormControl(''),
last_name: new FormControl(''),
is_active: new FormControl(true),
is_superuser: new FormControl(false),
groups: new FormControl([]),
user_permissions: new FormControl([]),
})
}
onToggleSuperUser() {
if (this.objectForm.get('is_superuser').value) {
this.objectForm.get('user_permissions').disable()
} else {
this.objectForm.get('user_permissions').enable()
}
}
get inheritedPermissions(): string[] {
const groupsVal: Array<number> = this.objectForm.get('groups').value
if (!groupsVal) return []
else
return groupsVal.flatMap(
(id) => this.groups.find((g) => g.id == id)?.permissions
)
}
}

View File

@ -1,5 +1,5 @@
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown"> <div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'"> <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" /> <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" />
</svg> </svg>
@ -25,10 +25,10 @@
</div> </div>
<div *ngIf="selectionModel.items" class="items"> <div *ngIf="selectionModel.items" class="items">
<ng-container *ngFor="let item of selectionModel.itemsSorted | filter: filterText"> <ng-container *ngFor="let item of selectionModel.itemsSorted | filter: filterText">
<app-toggleable-dropdown-button *ngIf="allowSelectNone || item.id" [item]="item" [state]="selectionModel.get(item.id)" (toggle)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)"></app-toggleable-dropdown-button> <app-toggleable-dropdown-button *ngIf="allowSelectNone || item.id" [item]="item" [state]="selectionModel.get(item.id)" (toggle)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" [disabled]="disabled"></app-toggleable-dropdown-button>
</ng-container> </ng-container>
</div> </div>
<button *ngIf="editing" class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty"> <button *ngIf="editing" class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small> <small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
<svg width="1.5em" height="1em" viewBox="0 0 16 16" fill="currentColor"> <svg width="1.5em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-right" /> <use xlink:href="assets/bootstrap-icons.svg#arrow-right" />

View File

@ -317,6 +317,9 @@ export class FilterableDropdownComponent {
@Input() @Input()
applyOnClose = false applyOnClose = false
@Input()
disabled = false
@Output() @Output()
apply = new EventEmitter<ChangedItems>() apply = new EventEmitter<ChangedItems>()

View File

@ -1,4 +1,4 @@
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="toggleItem($event)"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="toggleItem($event)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
<ng-container *ngIf="isChecked()"> <ng-container *ngIf="isChecked()">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">

View File

@ -23,6 +23,9 @@ export class ToggleableDropdownButtonComponent {
@Input() @Input()
count: number count: number
@Input()
disabled: boolean = false
@Output() @Output()
toggle = new EventEmitter() toggle = new EventEmitter()

View File

@ -3,8 +3,8 @@
<div class="input-group" [class.is-invalid]="error"> <div class="input-group" [class.is-invalid]="error">
<input class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10" <input class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)" (dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel"> name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button"> <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
</svg> </svg>

View File

@ -1,8 +1,8 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label" [for]="inputId">{{title}}</label> <label class="form-label" [for]="inputId">{{title}}</label>
<div class="input-group" [class.is-invalid]="error"> <div class="input-group" [class.is-invalid]="error">
<input type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error"> <input type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
<button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="value">+1</button> <button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
</div> </div>
<div class="invalid-feedback"> <div class="invalid-feedback">
{{error}} {{error}}

View File

@ -0,0 +1,68 @@
<ng-container *ngIf="!accordion">
<h5 i18n>Permissions</h5>
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
</ng-container>
<ng-container *ngIf="accordion">
<ngb-accordion #acc="ngbAccordion" activeIds="">
<ngb-panel i18n-title title="Edit Permissions">
<ng-template ngbPanelContent>
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
</ng-template>
</ngb-panel>
</ngb-accordion>
</ng-container>
<ng-template #permissionsForm>
<div [formGroup]="form">
<div class="row">
<div class="col-lg-3">
<label class="form-label d-block my-2" i18n>Owner:</label>
</div>
<div class="col-lg-9">
<app-input-select [items]="users" bindLabel="username" formControlName="owner" [allowNull]="true"></app-input-select>
</div>
</div>
<small class="form-text text-muted text-end d-block mt-n2" i18n>Objects without an owner can be viewed and edited by all users</small>
<div formGroupName="set_permissions">
<h6 class="mt-3" i18n>View</h6>
<div formGroupName="view" class="mb-2">
<div class="row mb-1">
<div class="col-lg-3">
<label class="form-label d-block my-2" i18n>Users:</label>
</div>
<div class="col-lg-9">
<app-permissions-user type="view" formControlName="users"></app-permissions-user>
</div>
</div>
<div class="row">
<div class="col-lg-3">
<label class="form-label d-block my-2" i18n>Groups:</label>
</div>
<div class="col-lg-9">
<app-permissions-group type="view" formControlName="groups"></app-permissions-group>
</div>
</div>
</div>
<h6 class="mt-4" i18n>Edit</h6>
<div formGroupName="change">
<div class="row mb-1">
<div class="col-lg-3">
<label class="form-label d-block my-2" i18n>Users:</label>
</div>
<div class="col-lg-9">
<app-permissions-user type="change" formControlName="users"></app-permissions-user>
</div>
</div>
<div class="row">
<div class="col-lg-3">
<label class="form-label d-block my-2" i18n>Groups:</label>
</div>
<div class="col-lg-9">
<app-permissions-group type="change" formControlName="groups"></app-permissions-group>
</div>
</div>
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
</div>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,69 @@
import { Component, forwardRef, Input, OnInit } from '@angular/core'
import { FormControl, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'
import { PaperlessUser } from 'src/app/data/paperless-user'
import { AbstractInputComponent } from '../../abstract-input'
export interface PermissionsFormObject {
owner?: number
set_permissions?: {
view?: {
users?: number[]
groups?: number[]
}
change?: {
users?: number[]
groups?: number[]
}
}
}
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PermissionsFormComponent),
multi: true,
},
],
selector: 'app-permissions-form',
templateUrl: './permissions-form.component.html',
styleUrls: ['./permissions-form.component.scss'],
})
export class PermissionsFormComponent
extends AbstractInputComponent<PermissionsFormObject>
implements OnInit
{
@Input()
users: PaperlessUser[]
@Input()
accordion: boolean = false
form = new FormGroup({
owner: new FormControl(null),
set_permissions: new FormGroup({
view: new FormGroup({
users: new FormControl([]),
groups: new FormControl([]),
}),
change: new FormGroup({
users: new FormControl([]),
groups: new FormControl([]),
}),
}),
})
constructor() {
super()
}
ngOnInit(): void {
this.form.valueChanges.subscribe((value) => {
this.onChange(value)
})
}
writeValue(newValue: any): void {
this.form.patchValue(newValue, { emitEvent: false })
}
}

View File

@ -0,0 +1,13 @@
<div class="paperless-input-select">
<div>
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
clearable="true"
[items]="groups"
multiple="true"
bindLabel="name"
bindValue="id"
(change)="onChange(value)">
</ng-select>
</div>
</div>

View File

@ -0,0 +1,30 @@
import { Component, forwardRef, Input, OnInit } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { first } from 'rxjs/operators'
import { PaperlessGroup } from 'src/app/data/paperless-group'
import { GroupService } from 'src/app/services/rest/group.service'
import { AbstractInputComponent } from '../../abstract-input'
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PermissionsGroupComponent),
multi: true,
},
],
selector: 'app-permissions-group',
templateUrl: './permissions-group.component.html',
styleUrls: ['./permissions-group.component.scss'],
})
export class PermissionsGroupComponent extends AbstractInputComponent<PaperlessGroup> {
groups: PaperlessGroup[]
constructor(groupService: GroupService) {
super()
groupService
.listAll()
.pipe(first())
.subscribe((result) => (this.groups = result.results))
}
}

View File

@ -0,0 +1,13 @@
<div class="paperless-input-select">
<div>
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
clearable="true"
[items]="users"
multiple="true"
bindLabel="username"
bindValue="id"
(change)="onChange(value)">
</ng-select>
</div>
</div>

View File

@ -0,0 +1,38 @@
import { Component, forwardRef, Input, OnInit } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { first } from 'rxjs/operators'
import { PaperlessUser } from 'src/app/data/paperless-user'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { AbstractInputComponent } from '../../abstract-input'
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PermissionsUserComponent),
multi: true,
},
],
selector: 'app-permissions-user',
templateUrl: './permissions-user.component.html',
styleUrls: ['./permissions-user.component.scss'],
})
export class PermissionsUserComponent extends AbstractInputComponent<
PaperlessUser[]
> {
users: PaperlessUser[]
constructor(userService: UserService, settings: SettingsService) {
super()
userService
.listAll()
.pipe(first())
.subscribe(
(result) =>
(this.users = result.results.filter(
(u) => u.id !== settings.currentUser.id
))
)
}
}

View File

@ -1,5 +1,5 @@
<div class="mb-3 paperless-input-select"> <div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<label class="form-label" [for]="inputId">{{title}}</label> <label *ngIf="title" class="form-label" [for]="inputId">{{title}}</label>
<div [class.input-group]="allowCreateNew"> <div [class.input-group]="allowCreateNew">
<ng-select name="inputId" [(ngModel)]="value" <ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled" [disabled]="disabled"
@ -11,7 +11,8 @@
addTagText="Add item" addTagText="Add item"
i18n-addTagText="Used for both types, correspondents, storage paths" i18n-addTagText="Used for both types, correspondents, storage paths"
[placeholder]="placeholder" [placeholder]="placeholder"
bindLabel="name" [multiple]="multiple"
[bindLabel]="bindLabel"
bindValue="id" bindValue="id"
(change)="onChange(value)" (change)="onChange(value)"
(search)="onSearch($event)" (search)="onSearch($event)"
@ -19,7 +20,7 @@
(clear)="clearLastSearchTerm()" (clear)="clearLastSearchTerm()"
(blur)="onBlur()"> (blur)="onBlur()">
</ng-select> </ng-select>
<button *ngIf="allowCreateNew" class="btn btn-outline-secondary" type="button" (click)="addItem()"> <button *ngIf="allowCreateNew" class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus" /> <use xlink:href="assets/bootstrap-icons.svg#plus" />
</svg> </svg>

View File

@ -1 +1,14 @@
// styles for ng-select child are in styles.scss // styles for ng-select child are in styles.scss
.paperless-input-select.disabled {
.input-group {
cursor: not-allowed;
}
::ng-deep ng-select {
pointer-events: none;
.ng-select-container {
background-color: var(--pngx-bg-alt) !important;
}
}
}

View File

@ -44,6 +44,12 @@ export class SelectComponent extends AbstractInputComponent<number> {
@Input() @Input()
placeholder: string placeholder: string
@Input()
multiple: boolean = false
@Input()
bindLabel: string = 'name'
@Output() @Output()
createNew = new EventEmitter<string>() createNew = new EventEmitter<string>()

View File

@ -1,8 +1,9 @@
<div class="mb-3 paperless-input-select paperless-input-tags"> <div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled">
<label class="form-label" for="tags" i18n>Tags</label> <label class="form-label" for="tags" i18n>Tags</label>
<div class="input-group flex-nowrap"> <div class="input-group flex-nowrap">
<ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value" <ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
[disabled]="disabled"
[multiple]="true" [multiple]="true"
[closeOnSelect]="false" [closeOnSelect]="false"
[clearSearchOnAdd]="true" [clearSearchOnAdd]="true"
@ -31,7 +32,7 @@
</ng-template> </ng-template>
</ng-select> </ng-select>
<button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()"> <button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus" /> <use xlink:href="assets/bootstrap-icons.svg#plus" />
</svg> </svg>

View File

@ -10,3 +10,17 @@
.tag-wrap-delete { .tag-wrap-delete {
cursor: pointer; cursor: pointer;
} }
.paperless-input-select.disabled {
.input-group {
cursor: not-allowed;
}
::ng-deep ng-select {
pointer-events: none;
.ng-select-container {
background-color: var(--pngx-bg-alt) !important;
}
}
}

View File

@ -74,6 +74,8 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
} }
removeTag(event: PointerEvent, id: number) { removeTag(event: PointerEvent, id: number) {
if (this.disabled) return
// prevent opening dropdown // prevent opening dropdown
event.stopImmediatePropagation() event.stopImmediatePropagation()

View File

@ -1,6 +1,6 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label" [for]="inputId">{{title}}</label> <label class="form-label" [for]="inputId">{{title}}</label>
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)"> <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
<div class="invalid-feedback"> <div class="invalid-feedback">
{{error}} {{error}}

View File

@ -0,0 +1,18 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancelClicked()">
</button>
</div>
<div class="modal-body">
<p class="mb-3" *ngIf="message" [innerHTML]="message | safeHtml"></p>
<form [formGroup]="form">
<app-permissions-form [users]="users" formControlName="permissions_form"></app-permissions-form>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="confirmClicked.emit(permissions)" i18n>Confirm</button>
</div>

View File

@ -0,0 +1,46 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PaperlessUser } from 'src/app/data/paperless-user'
import { UserService } from 'src/app/services/rest/user.service'
@Component({
selector: 'app-permissions-dialog',
templateUrl: './permissions-dialog.component.html',
styleUrls: ['./permissions-dialog.component.scss'],
})
export class PermissionsDialogComponent {
users: PaperlessUser[]
constructor(
public activeModal: NgbActiveModal,
private userService: UserService
) {
this.userService.listAll().subscribe((r) => (this.users = r.results))
}
@Output()
public confirmClicked = new EventEmitter()
@Input()
title = $localize`Set Permissions`
form = new FormGroup({
permissions_form: new FormControl(),
})
get permissions() {
return {
owner: this.form.get('permissions_form').value?.owner ?? null,
set_permissions:
this.form.get('permissions_form').value?.set_permissions ?? null,
}
}
@Input()
message = $localize`Note that permissions set here will override any existing permissions`
cancelClicked() {
this.activeModal.close()
}
}

View File

@ -0,0 +1,27 @@
<form [formGroup]="form" [class.opacity-50]="disabled">
<label class="form-label">{{title}}</label>
<ul class="list-group">
<li class="list-group-item d-flex">
<div class="col-3" i18n>Type</div>
<div class="col" i18n>All</div>
<div class="col" i18n>Add</div>
<div class="col" i18n>Change</div>
<div class="col" i18n>Delete</div>
<div class="col" i18n>View</div>
</li>
<li class="list-group-item d-flex" *ngFor="let type of PermissionType | keyvalue" [formGroupName]="type.key">
<div class="col-3">{{type.key}}:</div>
<div class="col form-check form-check-inline form-switch" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type.key)" placement="left" triggers="mouseenter:mouseleave">
<input type="checkbox" class="form-check-input" id="{{type.key}}_all" (change)="toggleAll($event, type.key)" [checked]="typesWithAllActions.has(type.key) || isInherited(type.key)" [attr.disabled]="disabled || isInherited(type.key) ? true : null">
<label class="form-check-label visually-hidden" for="{{type.key}}_all" i18n>All</label>
</div>
<div *ngFor="let action of PermissionAction | keyvalue" class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type.key, action.key)" placement="left" triggers="mouseenter:mouseleave">
<input type="checkbox" class="form-check-input" id="{{type.key}}_{{action.key}}" formControlName="{{action.key}}" [attr.disabled]="isDisabled(type.key, action.key)">
<label class="form-check-label visually-hidden" for="{{type.key}}_{{action.key}}" i18n>{{action.key}}</label>
</div>
</li>
<div *ngIf="error" class="invalid-feedback d-block">{{error}}</div>
</ul>
</form>

View File

@ -0,0 +1,189 @@
import { Component, forwardRef, Input, OnInit } from '@angular/core'
import {
ControlValueAccessor,
FormControl,
FormGroup,
NG_VALUE_ACCESSOR,
} from '@angular/forms'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PermissionsSelectComponent),
multi: true,
},
],
selector: 'app-permissions-select',
templateUrl: './permissions-select.component.html',
styleUrls: ['./permissions-select.component.scss'],
})
export class PermissionsSelectComponent
implements OnInit, ControlValueAccessor
{
PermissionType = PermissionType
PermissionAction = PermissionAction
@Input()
title: string = 'Permissions'
@Input()
error: string
permissions: string[]
form = new FormGroup({})
typesWithAllActions: Set<string> = new Set()
_inheritedPermissions: string[] = []
@Input()
set inheritedPermissions(inherited: string[]) {
// remove <app_label>. from permission strings
const newInheritedPermissions = inherited?.length
? inherited.map((p) => p.replace(/^\w+\./g, ''))
: []
if (this._inheritedPermissions !== newInheritedPermissions) {
this._inheritedPermissions = newInheritedPermissions
this.writeValue(this.permissions) // updates visual checks etc.
}
}
inheritedWarning: string = $localize`Inerhited from group`
constructor(private readonly permissionsService: PermissionsService) {
for (const type in PermissionType) {
const control = new FormGroup({})
for (const action in PermissionAction) {
control.addControl(action, new FormControl(null))
}
this.form.addControl(type, control)
}
}
writeValue(permissions: string[]): void {
this.permissions = permissions ?? []
const allPerms = this._inheritedPermissions.concat(this.permissions)
allPerms.forEach((permissionStr) => {
const { actionKey, typeKey } =
this.permissionsService.getPermissionKeys(permissionStr)
if (actionKey && typeKey) {
if (this.form.get(typeKey)?.get(actionKey)) {
this.form
.get(typeKey)
.get(actionKey)
.patchValue(true, { emitEvent: false })
}
}
})
Object.keys(PermissionType).forEach((type) => {
if (
Object.values(this.form.get(type).value).every((val) => val == true)
) {
this.typesWithAllActions.add(type)
} else {
this.typesWithAllActions.delete(type)
}
})
}
onChange = (newValue: string[]) => {}
onTouched = () => {}
disabled: boolean = false
registerOnChange(fn: any): void {
this.onChange = fn
}
registerOnTouched(fn: any): void {
this.onTouched = fn
}
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled
}
ngOnInit(): void {
this.form.valueChanges.subscribe((newValue) => {
let permissions = []
Object.entries(newValue).forEach(([typeKey, typeValue]) => {
// e.g. [Document, { Add: true, View: true ... }]
const selectedActions = Object.entries(typeValue).filter(
([actionKey, actionValue]) => actionValue == true
)
selectedActions.forEach(([actionKey, actionValue]) => {
permissions.push(
(PermissionType[typeKey] as string).replace(
'%s',
PermissionAction[actionKey]
)
)
})
if (selectedActions.length == Object.entries(typeValue).length) {
this.typesWithAllActions.add(typeKey)
} else {
this.typesWithAllActions.delete(typeKey)
}
})
this.onChange(permissions)
})
}
toggleAll(event, type) {
const typeGroup = this.form.get(type)
if (event.target.checked) {
Object.keys(PermissionAction).forEach((action) => {
typeGroup.get(action).patchValue(true)
})
this.typesWithAllActions.add(type)
} else {
Object.keys(PermissionAction).forEach((action) => {
typeGroup.get(action).patchValue(false)
})
this.typesWithAllActions.delete(type)
}
}
isInherited(typeKey: string, actionKey: string = null) {
if (this._inheritedPermissions.length == 0) return false
else if (actionKey) {
return this._inheritedPermissions.includes(
this.permissionsService.getPermissionCode(
PermissionAction[actionKey],
PermissionType[typeKey]
)
)
} else {
return Object.values(PermissionAction).every((action) => {
return this._inheritedPermissions.includes(
this.permissionsService.getPermissionCode(
action as PermissionAction,
PermissionType[typeKey]
)
)
})
}
}
// if checkbox is disabled either because "All", inhereted or entire component disabled
isDisabled(typeKey: string, actionKey: string) {
return this.typesWithAllActions.has(typeKey) ||
this.isInherited(typeKey, actionKey) ||
this.disabled
? true
: null
}
}

View File

@ -28,12 +28,14 @@
<app-welcome-widget *ngIf="settingsService.offerTour()" tourAnchor="tour.dashboard"></app-welcome-widget> <app-welcome-widget *ngIf="settingsService.offerTour()" tourAnchor="tour.dashboard"></app-welcome-widget>
<ng-container *ngFor="let v of savedViewService.dashboardViews; first as isFirst"> <div *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<app-saved-view-widget *ngIf="isFirst; else noTour" [savedView]="v" tourAnchor="tour.dashboard"></app-saved-view-widget> <ng-container *ngFor="let v of savedViewService.dashboardViews; first as isFirst">
<ng-template #noTour> <app-saved-view-widget *ngIf="isFirst; else noTour" [savedView]="v" tourAnchor="tour.dashboard"></app-saved-view-widget>
<app-saved-view-widget [savedView]="v"></app-saved-view-widget> <ng-template #noTour>
</ng-template> <app-saved-view-widget [savedView]="v"></app-saved-view-widget>
</ng-container> </ng-template>
</ng-container>
</div>
</div> </div>
<div class="col-lg-4"> <div class="col-lg-4">

View File

@ -1,17 +1,20 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
templateUrl: './dashboard.component.html', templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'], styleUrls: ['./dashboard.component.scss'],
}) })
export class DashboardComponent { export class DashboardComponent extends ComponentWithPermissions {
constructor( constructor(
public savedViewService: SavedViewService, public savedViewService: SavedViewService,
public settingsService: SettingsService public settingsService: SettingsService
) {} ) {
super()
}
get subtitle() { get subtitle() {
if (this.settingsService.displayName) { if (this.settingsService.displayName) {

View File

@ -1,6 +1,6 @@
<app-widget-frame [title]="savedView.name" [loading]="loading"> <app-widget-frame [title]="savedView.name" [loading]="loading">
<a class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a> <a class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" i18n>Show all</a>
<table content class="table table-sm table-hover table-borderless mb-0"> <table content class="table table-sm table-hover table-borderless mb-0">
@ -10,7 +10,7 @@
<th scope="col" i18n>Title</th> <th scope="col" i18n>Title</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<tr *ngFor="let doc of documents"> <tr *ngFor="let doc of documents">
<td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td> <td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td>
<td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></app-tag></a></td> <td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></app-tag></a></td>

View File

@ -9,13 +9,17 @@ import { PaperlessTag } from 'src/app/data/paperless-tag'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
@Component({ @Component({
selector: 'app-saved-view-widget', selector: 'app-saved-view-widget',
templateUrl: './saved-view-widget.component.html', templateUrl: './saved-view-widget.component.html',
styleUrls: ['./saved-view-widget.component.scss'], styleUrls: ['./saved-view-widget.component.scss'],
}) })
export class SavedViewWidgetComponent implements OnInit, OnDestroy { export class SavedViewWidgetComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
loading: boolean = true loading: boolean = true
constructor( constructor(
@ -24,7 +28,9 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy {
private list: DocumentListViewService, private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService, private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService public openDocumentsService: OpenDocumentsService
) {} ) {
super()
}
@Input() @Input()
savedView: PaperlessSavedView savedView: PaperlessSavedView
@ -74,6 +80,7 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy {
clickTag(tag: PaperlessTag, event: MouseEvent) { clickTag(tag: PaperlessTag, event: MouseEvent) {
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation()
this.list.quickFilter([ this.list.quickFilter([
{ rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() }, { rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() },

View File

@ -9,7 +9,7 @@
</a> </a>
</div> </div>
<div content tourAnchor="tour.upload-widget"> <div content tourAnchor="tour.upload-widget">
<form> <form *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }">
<ngx-file-drop dropZoneLabel="Drop documents here or" browseBtnLabel="Browse files" (onFileDrop)="dropped($event)" <ngx-file-drop dropZoneLabel="Drop documents here or" browseBtnLabel="Browse files" (onFileDrop)="dropped($event)"
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card" (onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card"
multiple="true" contentClassName="justify-content-center d-flex align-items-center py-5 px-2" [showBrowseBtn]=true multiple="true" contentClassName="justify-content-center d-flex align-items-center py-5 px-2" [showBrowseBtn]=true
@ -40,13 +40,15 @@
<h6 class="alert-heading">{{status.filename}}</h6> <h6 class="alert-heading">{{status.filename}}</h6>
<p class="mb-0 pb-1" *ngIf="!isFinished(status) || (isFinished(status) && !status.documentId)">{{status.message}}</p> <p class="mb-0 pb-1" *ngIf="!isFinished(status) || (isFinished(status) && !status.documentId)">{{status.message}}</p>
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar> <ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *ngIf="isFinished(status)"> <div *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)"> <div *ngIf="isFinished(status)">
<small i18n>Open document</small> <button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)">
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16"> <small i18n>Open document</small>
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/> <svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
</svg> <path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
</button> </svg>
</button>
</div>
</div> </div>
</ngb-alert> </ngb-alert>
</ng-template> </ng-template>

View File

@ -1,5 +1,6 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { NgxFileDropEntry } from 'ngx-file-drop' import { NgxFileDropEntry } from 'ngx-file-drop'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { import {
ConsumerStatusService, ConsumerStatusService,
FileStatus, FileStatus,
@ -14,13 +15,15 @@ const MAX_ALERTS = 5
templateUrl: './upload-file-widget.component.html', templateUrl: './upload-file-widget.component.html',
styleUrls: ['./upload-file-widget.component.scss'], styleUrls: ['./upload-file-widget.component.scss'],
}) })
export class UploadFileWidgetComponent { export class UploadFileWidgetComponent extends ComponentWithPermissions {
alertsExpanded = false alertsExpanded = false
constructor( constructor(
private consumerStatusService: ConsumerStatusService, private consumerStatusService: ConsumerStatusService,
private uploadDocumentsService: UploadDocumentsService private uploadDocumentsService: UploadDocumentsService
) {} ) {
super()
}
getStatus() { getStatus() {
return this.consumerStatusService.getConsumerStatus().slice(0, MAX_ALERTS) return this.consumerStatusService.getConsumerStatus().slice(0, MAX_ALERTS)

View File

@ -1,5 +1,5 @@
<div *ngIf="comments"> <div *ngIf="comments">
<form [formGroup]="commentForm" class="needs-validation mt-3" novalidate> <form [formGroup]="commentForm" class="needs-validation mt-3" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Comment }" novalidate>
<div class="form-group"> <div class="form-group">
<textarea class="form-control form-control-sm" [class.is-invalid]="newCommentError" rows="3" formControlName="newComment" placeholder="Enter comment" i18n-placeholder required></textarea> <textarea class="form-control form-control-sm" [class.is-invalid]="newCommentError" rows="3" formControlName="newComment" placeholder="Enter comment" i18n-placeholder required></textarea>
<div class="invalid-feedback" i18n> <div class="invalid-feedback" i18n>
@ -18,7 +18,7 @@
</div> </div>
<div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center"> <div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center">
<span>{{displayName(comment)}} - {{ comment.created | customDate}}</span> <span>{{displayName(comment)}} - {{ comment.created | customDate}}</span>
<button type="button" class="btn btn-link btn-sm p-0 fade" (click)="deleteComment(comment.id)"> <button type="button" class="btn btn-link btn-sm p-0 fade" (click)="deleteComment(comment.id)" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Comment }">
<svg width="13" height="13" fill="currentColor"> <svg width="13" height="13" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" /> <use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg> </svg>

View File

@ -4,13 +4,14 @@ import { PaperlessDocumentComment } from 'src/app/data/paperless-document-commen
import { FormControl, FormGroup } from '@angular/forms' import { FormControl, FormGroup } from '@angular/forms'
import { first } from 'rxjs/operators' import { first } from 'rxjs/operators'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
@Component({ @Component({
selector: 'app-document-comments', selector: 'app-document-comments',
templateUrl: './document-comments.component.html', templateUrl: './document-comments.component.html',
styleUrls: ['./document-comments.component.scss'], styleUrls: ['./document-comments.component.scss'],
}) })
export class DocumentCommentsComponent { export class DocumentCommentsComponent extends ComponentWithPermissions {
commentForm: FormGroup = new FormGroup({ commentForm: FormGroup = new FormGroup({
newComment: new FormControl(''), newComment: new FormControl(''),
}) })
@ -32,7 +33,9 @@ export class DocumentCommentsComponent {
constructor( constructor(
private commentsService: DocumentCommentsService, private commentsService: DocumentCommentsService,
private toastService: ToastService private toastService: ToastService
) {} ) {
super()
}
update(): void { update(): void {
this.networkActive = true this.networkActive = true
@ -89,8 +92,8 @@ export class DocumentCommentsComponent {
displayName(comment: PaperlessDocumentComment): string { displayName(comment: PaperlessDocumentComment): string {
if (!comment.user) return '' if (!comment.user) return ''
let nameComponents = [] let nameComponents = []
if (comment.user.firstname) nameComponents.unshift(comment.user.firstname) if (comment.user.first_name) nameComponents.unshift(comment.user.first_name)
if (comment.user.lastname) nameComponents.unshift(comment.user.lastname) if (comment.user.last_name) nameComponents.unshift(comment.user.last_name)
if (comment.user.username) { if (comment.user.username) {
if (nameComponents.length > 0) if (nameComponents.length > 0)
nameComponents.push(`(${comment.user.username})`) nameComponents.push(`(${comment.user.username})`)

View File

@ -5,7 +5,7 @@
<div class="input-group-text" i18n>of {{previewNumPages}}</div> <div class="input-group-text" i18n>of {{previewNumPages}}</div>
</div> </div>
<button type="button" class="btn btn-sm btn-outline-danger me-2 ms-auto" (click)="delete()"> <button type="button" class="btn btn-sm btn-outline-danger me-2 ms-auto" (click)="delete()" [disabled]="!userIsOwner">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" /> <use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg><span class="d-none d-lg-inline ps-1" i18n>Delete</span> </svg><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
@ -20,7 +20,7 @@
</a> </a>
<div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version"> <div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version">
<button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button> <button class="btn btn-sm btn-outline-primary dropdown-toggle" ngbDropdownToggle></button>
<div class="dropdown-menu shadow" ngbDropdownMenu> <div class="dropdown-menu shadow" ngbDropdownMenu>
<a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a> <a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
</div> </div>
@ -28,7 +28,7 @@
</div> </div>
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="redoOcr()"> <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="redoOcr()" [disabled]="!userCanEdit">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" /> <use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" />
</svg><span class="d-none d-lg-inline ps-1" i18n>Redo OCR</span> </svg><span class="d-none d-lg-inline ps-1" i18n>Redo OCR</span>
@ -170,19 +170,31 @@
</div> </div>
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="5" *ngIf="commentsEnabled"> <li [ngbNavItem]="5" *ngIf="commentsEnabled">
<a ngbNavLink i18n>Comments</a> <a ngbNavLink i18n>Comments</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<app-document-comments [documentId]="documentId"></app-document-comments> <app-document-comments [documentId]="documentId"></app-document-comments>
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="6" *appIfOwner="document">
<a ngbNavLink i18n>Permissions</a>
<ng-template ngbNavContent>
<div class="mb-3">
<app-permissions-form [users]="users" formControlName="permissions_form"></app-permissions-form>
</div>
</ng-template>
</li>
</ul> </ul>
<div [ngbNavOutlet]="nav" class="mt-2"></div> <div [ngbNavOutlet]="nav" class="mt-2"></div>
<button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="networkActive || (isDirty$ | async) !== true">Discard</button>&nbsp; <ng-container>
<button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive || (isDirty$ | async) !== true || error">Save & next</button>&nbsp; <button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>&nbsp;
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || (isDirty$ | async) !== true || error">Save</button>&nbsp; <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true || error">Save & next</button>&nbsp;
<button type="submit" class="btn btn-primary" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true || error">Save</button>&nbsp;
</ng-container>
</form> </form>
</div> </div>

View File

@ -35,6 +35,13 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { PaperlessUser } from 'src/app/data/paperless-user'
import { UserService } from 'src/app/services/rest/user.service'
@Component({ @Component({
selector: 'app-document-detail', selector: 'app-document-detail',
@ -58,6 +65,7 @@ export class DocumentDetailComponent
document: PaperlessDocument document: PaperlessDocument
metadata: PaperlessDocumentMetadata metadata: PaperlessDocumentMetadata
suggestions: PaperlessDocumentSuggestions suggestions: PaperlessDocumentSuggestions
users: PaperlessUser[]
title: string title: string
titleSubject: Subject<string> = new Subject() titleSubject: Subject<string> = new Subject()
@ -78,6 +86,7 @@ export class DocumentDetailComponent
storage_path: new FormControl(), storage_path: new FormControl(),
archive_serial_number: new FormControl(), archive_serial_number: new FormControl(),
tags: new FormControl([]), tags: new FormControl([]),
permissions_form: new FormControl(null),
}) })
previewCurrentPage: number = 1 previewCurrentPage: number = 1
@ -106,6 +115,9 @@ export class DocumentDetailComponent
} }
} }
PermissionAction = PermissionAction
PermissionType = PermissionType
constructor( constructor(
private documentsService: DocumentService, private documentsService: DocumentService,
private route: ActivatedRoute, private route: ActivatedRoute,
@ -118,7 +130,9 @@ export class DocumentDetailComponent
private documentTitlePipe: DocumentTitlePipe, private documentTitlePipe: DocumentTitlePipe,
private toastService: ToastService, private toastService: ToastService,
private settings: SettingsService, private settings: SettingsService,
private storagePathService: StoragePathService private storagePathService: StoragePathService,
private permissionsService: PermissionsService,
private userService: UserService
) {} ) {}
titleKeyUp(event) { titleKeyUp(event) {
@ -147,7 +161,13 @@ export class DocumentDetailComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
this.error = null this.error = null
Object.assign(this.document, this.documentForm.value) const docValues = Object.assign({}, this.documentForm.value)
docValues['owner'] =
this.documentForm.get('permissions_form').value['owner']
docValues['set_permissions'] =
this.documentForm.get('permissions_form').value['set_permissions']
delete docValues['permissions_form']
Object.assign(this.document, docValues)
}) })
this.correspondentService this.correspondentService
@ -165,6 +185,11 @@ export class DocumentDetailComponent
.pipe(first()) .pipe(first())
.subscribe((result) => (this.storagePaths = result.results)) .subscribe((result) => (this.storagePaths = result.results))
this.userService
.listAll()
.pipe(first())
.subscribe((result) => (this.users = result.results))
this.route.paramMap this.route.paramMap
.pipe( .pipe(
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
@ -232,6 +257,10 @@ export class DocumentDetailComponent
storage_path: doc.storage_path, storage_path: doc.storage_path,
archive_serial_number: doc.archive_serial_number, archive_serial_number: doc.archive_serial_number,
tags: [...doc.tags], tags: [...doc.tags],
permissions_form: {
owner: doc.owner,
set_permissions: doc.permissions,
},
}) })
this.isDirty$ = dirtyCheck( this.isDirty$ = dirtyCheck(
@ -286,7 +315,14 @@ export class DocumentDetailComponent
}, },
}) })
this.title = this.documentTitlePipe.transform(doc.title) this.title = this.documentTitlePipe.transform(doc.title)
this.documentForm.patchValue(doc) const docFormValues = Object.assign({}, doc)
docFormValues['permissions_form'] = {
owner: doc.owner,
set_permissions: doc.permissions,
}
this.documentForm.patchValue(docFormValues, { emitEvent: false })
if (!this.userCanEdit) this.documentForm.disable()
} }
createDocumentType(newName: string) { createDocumentType(newName: string) {
@ -378,7 +414,7 @@ export class DocumentDetailComponent
.update(this.document) .update(this.document)
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (result) => { next: () => {
this.close() this.close()
this.networkActive = false this.networkActive = false
this.error = null this.error = null
@ -564,6 +600,36 @@ export class DocumentDetailComponent
} }
get commentsEnabled(): boolean { get commentsEnabled(): boolean {
return this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED) return (
this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED) &&
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Document
)
)
}
get userIsOwner(): boolean {
let doc: PaperlessDocument = Object.assign({}, this.document)
// dont disable while editing
if (this.document && this.store?.value.owner) {
doc.owner = this.store?.value.owner
}
return !this.document || this.permissionsService.currentUserOwnsObject(doc)
}
get userCanEdit(): boolean {
let doc: PaperlessDocument = Object.assign({}, this.document)
// dont disable while editing
if (this.document && this.store?.value.owner) {
doc.owner = this.store?.value.owner
}
return (
!this.document ||
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
doc
)
)
} }
} }

View File

@ -23,11 +23,12 @@
</div> </div>
<div class="w-100 d-xl-none"></div> <div class="w-100 d-xl-none"></div>
<div class="col-auto mb-2 mb-xl-0"> <div class="col-auto mb-2 mb-xl-0">
<div class="d-flex"> <div class="d-flex" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<label class="ms-auto mt-1 mb-0 me-2" i18n>Edit:</label> <label class="ms-auto mt-1 mb-0 me-2" i18n>Edit:</label>
<app-filterable-dropdown class="me-2 me-md-3" title="Tags" icon="tag-fill" i18n-title <app-filterable-dropdown class="me-2 me-md-3" title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags" [items]="tags"
[disabled]="!userCanEditAll"
[editing]="true" [editing]="true"
[multiple]="true" [multiple]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
@ -38,6 +39,7 @@
<app-filterable-dropdown class="me-2 me-md-3" title="Correspondent" icon="person-fill" i18n-title <app-filterable-dropdown class="me-2 me-md-3" title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents" [items]="correspondents"
[disabled]="!userCanEditAll"
[editing]="true" [editing]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
(opened)="openCorrespondentDropdown()" (opened)="openCorrespondentDropdown()"
@ -47,6 +49,7 @@
<app-filterable-dropdown class="me-2 me-md-3" title="Document type" icon="file-earmark-fill" i18n-title <app-filterable-dropdown class="me-2 me-md-3" title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes" [items]="documentTypes"
[disabled]="!userCanEditAll"
[editing]="true" [editing]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
(opened)="openDocumentTypeDropdown()" (opened)="openDocumentTypeDropdown()"
@ -56,6 +59,7 @@
<app-filterable-dropdown class="me-2 me-md-3" title="Storage path" icon="folder-fill" i18n-title <app-filterable-dropdown class="me-2 me-md-3" title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths" [items]="storagePaths"
[disabled]="!userCanEditAll"
[editing]="true" [editing]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
(opened)="openStoragePathDropdown()" (opened)="openStoragePathDropdown()"
@ -65,7 +69,14 @@
</div> </div>
</div> </div>
<div class="col-auto ms-auto mb-2 mb-xl-0 d-flex"> <div class="col-auto ms-auto mb-2 mb-xl-0 d-flex">
<div class="btn-group btn-group-sm me-2"> <div class="btn-toolbar me-2">
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person-fill-lock" />
</svg>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<div ngbDropdown class="me-2 d-flex"> <div ngbDropdown class="me-2 d-flex">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
@ -74,7 +85,7 @@
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="redoOcrSelected()" i18n>Redo OCR</button> <button ngbDropdownItem (click)="redoOcrSelected()" [disabled]="!userCanEditAll" i18n>Redo OCR</button>
</div> </div>
</div> </div>
</div> </div>
@ -120,7 +131,7 @@
</div> </div>
<div class="btn-group btn-group-sm me-2"> <div class="btn-group btn-group-sm me-2">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()"> <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" /> <use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>&nbsp;<ng-container i18n>Delete</ng-container> </svg>&nbsp;<ng-container i18n>Delete</ng-container>

View File

@ -25,6 +25,9 @@ import { saveAs } from 'file-saver'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { PermissionsService } from 'src/app/services/permissions.service'
import { FormControl, FormGroup } from '@angular/forms' import { FormControl, FormGroup } from '@angular/forms'
import { first, Subject, takeUntil } from 'rxjs' import { first, Subject, takeUntil } from 'rxjs'
@ -33,7 +36,10 @@ import { first, Subject, takeUntil } from 'rxjs'
templateUrl: './bulk-editor.component.html', templateUrl: './bulk-editor.component.html',
styleUrls: ['./bulk-editor.component.scss'], styleUrls: ['./bulk-editor.component.scss'],
}) })
export class BulkEditorComponent implements OnInit, OnDestroy { export class BulkEditorComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
tags: PaperlessTag[] tags: PaperlessTag[]
correspondents: PaperlessCorrespondent[] correspondents: PaperlessCorrespondent[]
documentTypes: PaperlessDocumentType[] documentTypes: PaperlessDocumentType[]
@ -63,8 +69,11 @@ export class BulkEditorComponent implements OnInit, OnDestroy {
private openDocumentService: OpenDocumentsService, private openDocumentService: OpenDocumentsService,
private settings: SettingsService, private settings: SettingsService,
private toastService: ToastService, private toastService: ToastService,
private storagePathService: StoragePathService private storagePathService: StoragePathService,
) {} private permissionService: PermissionsService
) {
super()
}
applyOnClose: boolean = this.settings.get( applyOnClose: boolean = this.settings.get(
SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE
@ -73,6 +82,25 @@ export class BulkEditorComponent implements OnInit, OnDestroy {
SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS
) )
get userCanEditAll(): boolean {
let canEdit: boolean = true
const docs = this.list.documents.filter((d) => this.list.selected.has(d.id))
canEdit = docs.every((d) =>
this.permissionService.currentUserHasObjectPermissions(
this.PermissionAction.Change,
d
)
)
return canEdit
}
get userOwnsAll(): boolean {
let ownsAll: boolean = true
const docs = this.list.documents.filter((d) => this.list.selected.has(d.id))
ownsAll = docs.every((d) => this.permissionService.currentUserOwnsObject(d))
return ownsAll
}
ngOnInit() { ngOnInit() {
this.tagService this.tagService
.listAll() .listAll()
@ -463,4 +491,14 @@ export class BulkEditorComponent implements OnInit, OnDestroy {
this.executeBulkOperation(modal, 'redo_ocr', {}) this.executeBulkOperation(modal, 'redo_ocr', {})
}) })
} }
setPermissions() {
let modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.confirmClicked.subscribe((permissions) => {
modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'set_permissions', permissions)
})
}
} }

View File

@ -43,7 +43,7 @@
<use xlink:href="assets/bootstrap-icons.svg#diagram-3"/> <use xlink:href="assets/bootstrap-icons.svg#diagram-3"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span> </svg>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
</a> </a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary"> <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<svg class="sidebaricon" fill="currentColor" class="sidebaricon"> <svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#pencil"/> <use xlink:href="assets/bootstrap-icons.svg#pencil"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span> </svg>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span>

View File

@ -10,6 +10,7 @@ import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
@Component({ @Component({
selector: 'app-document-card-large', selector: 'app-document-card-large',
@ -19,11 +20,13 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
'../popover-preview/popover-preview.scss', '../popover-preview/popover-preview.scss',
], ],
}) })
export class DocumentCardLargeComponent { export class DocumentCardLargeComponent extends ComponentWithPermissions {
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
private settingsService: SettingsService private settingsService: SettingsService
) {} ) {
super()
}
@Input() @Input()
selected = false selected = false

View File

@ -67,7 +67,7 @@
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="btn-group w-100"> <div class="btn-group w-100">
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title> <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n-title>
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg> </svg>

View File

@ -11,6 +11,7 @@ import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
@Component({ @Component({
selector: 'app-document-card-small', selector: 'app-document-card-small',
@ -20,11 +21,13 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
'../popover-preview/popover-preview.scss', '../popover-preview/popover-preview.scss',
], ],
}) })
export class DocumentCardSmallComponent { export class DocumentCardSmallComponent extends ComponentWithPermissions {
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
private settingsService: SettingsService private settingsService: SettingsService
) {} ) {
super()
}
@Input() @Input()
selected = false selected = false

View File

@ -59,7 +59,7 @@
</div> </div>
</div> </div>
<div class="btn-group ms-2 flex-fill" ngbDropdown role="group"> <div class="btn-group ms-2 flex-fill" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle>
<ng-container i18n>Views</ng-container> <ng-container i18n>Views</ng-container>
<div *ngIf="savedViewIsModified" class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle"> <div *ngIf="savedViewIsModified" class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle">
@ -72,8 +72,10 @@
<div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div> <div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div>
</ng-container> </ng-container>
<button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.activeSavedViewId" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button> <div *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }">
<button ngbDropdownItem (click)="saveViewConfigAs()" i18n>Save as...</button> <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.activeSavedViewId" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button>
</div>
<button ngbDropdownItem (click)="saveViewConfigAs()" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.SavedView }" i18n>Save as...</button>
</div> </div>
</div> </div>

View File

@ -30,6 +30,7 @@ import {
} from 'src/app/services/rest/document.service' } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { FilterEditorComponent } from './filter-editor/filter-editor.component' import { FilterEditorComponent } from './filter-editor/filter-editor.component'
import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component' import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component'
@ -38,7 +39,10 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
templateUrl: './document-list.component.html', templateUrl: './document-list.component.html',
styleUrls: ['./document-list.component.scss'], styleUrls: ['./document-list.component.scss'],
}) })
export class DocumentListComponent implements OnInit, OnDestroy { export class DocumentListComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
constructor( constructor(
public list: DocumentListViewService, public list: DocumentListViewService,
public savedViewService: SavedViewService, public savedViewService: SavedViewService,
@ -48,7 +52,9 @@ export class DocumentListComponent implements OnInit, OnDestroy {
private modalService: NgbModal, private modalService: NgbModal,
private consumerStatusService: ConsumerStatusService, private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService public openDocumentsService: OpenDocumentsService
) {} ) {
super()
}
@ViewChild('filterEditor') @ViewChild('filterEditor')
private filterEditor: FilterEditorComponent private filterEditor: FilterEditorComponent

View File

@ -4,6 +4,10 @@ import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@ -21,6 +25,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Paperles
modalService: NgbModal, modalService: NgbModal,
toastService: ToastService, toastService: ToastService,
documentListViewService: DocumentListViewService, documentListViewService: DocumentListViewService,
permissionsService: PermissionsService,
private datePipe: CustomDatePipe private datePipe: CustomDatePipe
) { ) {
super( super(
@ -29,9 +34,11 @@ export class CorrespondentListComponent extends ManagementListComponent<Paperles
CorrespondentEditDialogComponent, CorrespondentEditDialogComponent,
toastService, toastService,
documentListViewService, documentListViewService,
permissionsService,
FILTER_CORRESPONDENT, FILTER_CORRESPONDENT,
$localize`correspondent`, $localize`correspondent`,
$localize`correspondents`, $localize`correspondents`,
PermissionType.Correspondent,
[ [
{ {
key: 'last_correspondence', key: 'last_correspondence',

View File

@ -3,6 +3,10 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_DOCUMENT_TYPE } from 'src/app/data/filter-rule-type' import { FILTER_DOCUMENT_TYPE } from 'src/app/data/filter-rule-type'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@ -18,7 +22,8 @@ export class DocumentTypeListComponent extends ManagementListComponent<Paperless
documentTypeService: DocumentTypeService, documentTypeService: DocumentTypeService,
modalService: NgbModal, modalService: NgbModal,
toastService: ToastService, toastService: ToastService,
documentListViewService: DocumentListViewService documentListViewService: DocumentListViewService,
permissionsService: PermissionsService
) { ) {
super( super(
documentTypeService, documentTypeService,
@ -26,9 +31,11 @@ export class DocumentTypeListComponent extends ManagementListComponent<Paperless
DocumentTypeEditDialogComponent, DocumentTypeEditDialogComponent,
toastService, toastService,
documentListViewService, documentListViewService,
permissionsService,
FILTER_DOCUMENT_TYPE, FILTER_DOCUMENT_TYPE,
$localize`document type`, $localize`document type`,
$localize`document types`, $localize`document types`,
PermissionType.DocumentType,
[] []
) )
} }

View File

@ -1,5 +1,5 @@
<app-page-header title="{{ typeNamePlural | titlecase }}"> <app-page-header title="{{ typeNamePlural | titlecase }}">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" i18n>Create</button> <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *appIfPermissions="{ action: PermissionAction.Add, type: permissionType }" i18n>Create</button>
</app-page-header> </app-page-header>
<div class="row"> <div class="row">
@ -41,24 +41,24 @@
</svg> </svg>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> <div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="filterDocuments(object)" ngbDropdownItem i18n>Filter Documents</button> <button (click)="filterDocuments(object)" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents</button>
<button (click)="openEditDialog(object)" ngbDropdownItem i18n>Edit</button> <button (click)="openEditDialog(object)" *appIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="openDeleteDialog(object)" ngbDropdownItem i18n>Delete</button> <button class="text-danger" (click)="openDeleteDialog(object)" *appIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
</div> </div>
</div> </div>
</div> </div>
<div class="btn-group d-none d-sm-block"> <div class="btn-group d-none d-sm-block">
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object)"> <button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object)" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/> <path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/>
</svg>&nbsp;<ng-container i18n>Documents</ng-container> </svg>&nbsp;<ng-container i18n>Documents</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object)"> <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object)" *appIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>&nbsp;<ng-container i18n>Edit</ng-container> </svg>&nbsp;<ng-container i18n>Edit</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object)"> <button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object)" *appIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/> <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/> <path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>

View File

@ -14,14 +14,20 @@ import {
MATCH_AUTO, MATCH_AUTO,
} from 'src/app/data/matching-model' } from 'src/app/data/matching-model'
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { import {
SortableDirective, SortableDirective,
SortEvent, SortEvent,
} from 'src/app/directives/sortable.directive' } from 'src/app/directives/sortable.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service' import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
export interface ManagementListColumn { export interface ManagementListColumn {
key: string key: string
@ -35,6 +41,7 @@ export interface ManagementListColumn {
@Directive() @Directive()
export abstract class ManagementListComponent<T extends ObjectWithId> export abstract class ManagementListComponent<T extends ObjectWithId>
extends ComponentWithPermissions
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
constructor( constructor(
@ -43,11 +50,15 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
private editDialogComponent: any, private editDialogComponent: any,
private toastService: ToastService, private toastService: ToastService,
private documentListViewService: DocumentListViewService, private documentListViewService: DocumentListViewService,
private permissionsService: PermissionsService,
protected filterRuleType: number, protected filterRuleType: number,
public typeName: string, public typeName: string,
public typeNamePlural: string, public typeNamePlural: string,
public permissionType: PermissionType,
public extraColumns: ManagementListColumn[] public extraColumns: ManagementListColumn[]
) {} ) {
super()
}
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective> @ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
@ -209,4 +220,15 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
onNameFilterKeyUp(event: KeyboardEvent) { onNameFilterKeyUp(event: KeyboardEvent) {
if (event.code == 'Escape') this.nameFilterDebounce.next(null) if (event.code == 'Escape') this.nameFilterDebounce.next(null)
} }
userCanDelete(object: ObjectWithPermissions): boolean {
return this.permissionsService.currentUserOwnsObject(object)
}
userCanEdit(object: ObjectWithPermissions): boolean {
return this.permissionsService.currentUserHasObjectPermissions(
this.PermissionAction.Change,
object
)
}
} }

View File

@ -1,6 +1,6 @@
<app-page-header title="Settings" i18n-title> <app-page-header title="Settings" i18n-title>
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button> <button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button>
<a class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank"> <a *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank">
<ng-container i18n>Open Django Admin</ng-container> <ng-container i18n>Open Django Admin</ng-container>
<svg class="sidebaricon ms-1" fill="currentColor"> <svg class="sidebaricon ms-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-up-right"/> <use xlink:href="assets/bootstrap-icons.svg#arrow-up-right"/>
@ -219,7 +219,7 @@
<div class="mb-2 col-auto"> <div class="mb-2 col-auto">
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label> <label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)" i18n>Delete</button> <button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" i18n>Delete</button>
</div> </div>
</div> </div>
@ -235,80 +235,84 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="SettingsNavIDs.Mail" (mouseover)="maybeInitializeTab(SettingsNavIDs.Mail)" (focusin)="maybeInitializeTab(SettingsNavIDs.Mail)"> <li *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }" [ngbNavItem]="SettingsNavIDs.Mail" (mouseover)="maybeInitializeTab(SettingsNavIDs.Mail)" (focusin)="maybeInitializeTab(SettingsNavIDs.Mail)">
<a ngbNavLink i18n>Mail</a> <a ngbNavLink i18n>Mail</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<ng-container *ngIf="mailAccounts && mailRules"> <ng-container *ngIf="mailAccounts && mailRules">
<h4> <ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }">
<ng-container i18n>Mail accounts</ng-container> <h4>
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()"> <ng-container i18n>Mail accounts</ng-container>
<svg class="sidebaricon me-1" fill="currentColor"> <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> <svg class="sidebaricon me-1" fill="currentColor">
</svg> <use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
<ng-container i18n>Add Account</ng-container> </svg>
</button> <ng-container i18n>Add Account</ng-container>
</h4> </button>
<ul class="list-group" formGroupName="mailAccounts"> </h4>
<ul class="list-group" formGroupName="mailAccounts">
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row">
<div class="col" i18n>Name</div> <div class="col" i18n>Name</div>
<div class="col" i18n>Server</div> <div class="col" i18n>Server</div>
<div class="col" i18n>Actions</div> <div class="col" i18n>Actions</div>
</div> </div>
</li> </li>
<li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id"> <li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id">
<div class="row"> <div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)">{{account.name}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)">{{account.name}}</button></div>
<div class="col d-flex align-items-center">{{account.imap_server}}</div> <div class="col d-flex align-items-center">{{account.imap_server}}</div>
<div class="col"> <div class="col">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-primary" type="button" (click)="editMailAccount(account)" i18n>Edit</button> <button *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" class="btn btn-sm btn-primary" type="button" (click)="editMailAccount(account)" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)" i18n>Delete</button> <button *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)" i18n>Delete</button>
</div>
</div> </div>
</div> </div>
</div> </li>
</li>
<div *ngIf="mailAccounts.length === 0" i18n>No mail accounts defined.</div> <div *ngIf="mailAccounts.length === 0" i18n>No mail accounts defined.</div>
</ul> </ul>
</ng-container>
<h4 class="mt-4"> <ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }">
<ng-container i18n>Mail rules</ng-container> <h4 class="mt-4">
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()"> <ng-container i18n>Mail rules</ng-container>
<svg class="sidebaricon me-1" fill="currentColor"> <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> <svg class="sidebaricon me-1" fill="currentColor">
</svg> <use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
<ng-container i18n>Add Rule</ng-container> </svg>
</button> <ng-container i18n>Add Rule</ng-container>
</h4> </button>
<ul class="list-group" formGroupName="mailRules"> </h4>
<ul class="list-group" formGroupName="mailRules">
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row">
<div class="col" i18n>Name</div> <div class="col" i18n>Name</div>
<div class="col" i18n>Account</div> <div class="col" i18n>Account</div>
<div class="col" i18n>Actions</div> <div class="col" i18n>Actions</div>
</div> </div>
</li> </li>
<li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id"> <li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id">
<div class="row"> <div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)">{{rule.name}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)">{{rule.name}}</button></div>
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col"> <div class="col">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-primary" type="button" (click)="editMailRule(rule)" i18n>Edit</button> <button *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" class="btn btn-sm btn-primary" type="button" (click)="editMailRule(rule)" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)" i18n>Delete</button> <button *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)" i18n>Delete</button>
</div>
</div> </div>
</div> </div>
</div> </li>
</li>
<div *ngIf="mailRules.length === 0" i18n>No mail rules defined.</div> <div *ngIf="mailRules.length === 0" i18n>No mail rules defined.</div>
</ul> </ul>
</ng-container>
</ng-container> </ng-container>
<div *ngIf="!mailAccounts || !mailRules"> <div *ngIf="!mailAccounts || !mailRules">
@ -318,9 +322,95 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="SettingsNavIDs.UsersGroups" *appIfPermissions="{ action: PermissionAction.Add, 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()">
<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)">{{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)" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(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()">
<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)">{{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)" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(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> </ul>
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button> <button type="submit" class="btn btn-primary mb-2" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
</form> </form>

View File

@ -29,15 +29,21 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { ViewportScroller } from '@angular/common' import { ViewportScroller } from '@angular/common'
import { TourService } from 'ngx-ui-tour-ng-bootstrap' 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 { PaperlessMailAccount } from 'src/app/data/paperless-mail-account'
import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule' import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule'
import { MailAccountService } from 'src/app/services/rest/mail-account.service' import { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service' import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-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 { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
enum SettingsNavIDs { enum SettingsNavIDs {
General = 1, General = 1,
@ -53,12 +59,15 @@ enum SettingsNavIDs {
styleUrls: ['./settings.component.scss'], styleUrls: ['./settings.component.scss'],
}) })
export class SettingsComponent export class SettingsComponent
extends ComponentWithPermissions
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
{ {
SettingsNavIDs = SettingsNavIDs SettingsNavIDs = SettingsNavIDs
activeNavID: number activeNavID: number
savedViewGroup = new FormGroup({}) savedViewGroup = new FormGroup({})
usersGroup = new FormGroup({})
groupsGroup = new FormGroup({})
mailAccountGroup = new FormGroup({}) mailAccountGroup = new FormGroup({})
mailRuleGroup = new FormGroup({}) mailRuleGroup = new FormGroup({})
@ -83,6 +92,8 @@ export class SettingsComponent
notificationsConsumerSuccess: new FormControl(null), notificationsConsumerSuccess: new FormControl(null),
notificationsConsumerFailed: new FormControl(null), notificationsConsumerFailed: new FormControl(null),
notificationsConsumerSuppressOnDashboard: new FormControl(null), notificationsConsumerSuppressOnDashboard: new FormControl(null),
usersGroup: this.usersGroup,
groupsGroup: this.groupsGroup,
savedViewsWarnOnUnsavedChange: new FormControl(null), savedViewsWarnOnUnsavedChange: new FormControl(null),
savedViews: this.savedViewGroup, savedViews: this.savedViewGroup,
@ -103,6 +114,9 @@ export class SettingsComponent
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
savePending: boolean = false savePending: boolean = false
users: PaperlessUser[]
groups: PaperlessGroup[]
get computedDateLocale(): string { get computedDateLocale(): string {
return ( return (
this.settingsForm.value.dateLocale || this.settingsForm.value.dateLocale ||
@ -121,10 +135,13 @@ export class SettingsComponent
@Inject(LOCALE_ID) public currentLocale: string, @Inject(LOCALE_ID) public currentLocale: string,
private viewportScroller: ViewportScroller, private viewportScroller: ViewportScroller,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private router: Router,
public readonly tourService: TourService, public readonly tourService: TourService,
private usersService: UserService,
private groupsService: GroupService,
private router: Router,
private modalService: NgbModal private modalService: NgbModal
) { ) {
super()
this.settings.settingsSaved.subscribe(() => { this.settings.settingsSaved.subscribe(() => {
if (!this.savePending) this.initialize() if (!this.savePending) this.initialize()
}) })
@ -198,6 +215,8 @@ export class SettingsComponent
savedViewsWarnOnUnsavedChange: this.settings.get( savedViewsWarnOnUnsavedChange: this.settings.get(
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
), ),
usersGroup: {},
groupsGroup: {},
savedViews: {}, savedViews: {},
mailAccounts: {}, mailAccounts: {},
mailRules: {}, mailRules: {},
@ -229,6 +248,17 @@ export class SettingsComponent
this.savedViews = r.results this.savedViews = r.results
this.initialize(false) this.initialize(false)
}) })
} else if (
navID == SettingsNavIDs.UsersGroups &&
(!this.users || !this.groups)
) {
this.usersService.listAll().subscribe((r) => {
this.users = r.results
this.groupsService.listAll().subscribe((r) => {
this.groups = r.results
this.initialize(false)
})
})
} else if ( } else if (
navID == SettingsNavIDs.Mail && navID == SettingsNavIDs.Mail &&
(!this.mailAccounts || !this.mailRules) (!this.mailAccounts || !this.mailRules)
@ -271,6 +301,50 @@ export class SettingsComponent
} }
} }
if (this.users && this.groups) {
for (let user of this.users) {
storeData.usersGroup[user.id.toString()] = {
id: user.id,
username: user.username,
first_name: user.first_name,
last_name: user.last_name,
is_active: user.is_active,
is_superuser: user.is_superuser,
groups: user.groups,
user_permissions: user.user_permissions,
}
this.usersGroup.addControl(
user.id.toString(),
new FormGroup({
id: new FormControl(null),
username: new FormControl(null),
first_name: new FormControl(null),
last_name: new FormControl(null),
is_active: new FormControl(null),
is_superuser: new FormControl(null),
groups: new FormControl(null),
user_permissions: new FormControl(null),
})
)
}
for (let group of this.groups) {
storeData.groupsGroup[group.id.toString()] = {
id: group.id,
name: group.name,
permissions: group.permissions,
}
this.groupsGroup.addControl(
group.id.toString(),
new FormGroup({
id: new FormControl(null),
name: new FormControl(null),
permissions: new FormControl(null),
})
)
}
}
if (this.mailAccounts && this.mailRules) { if (this.mailAccounts && this.mailRules) {
for (let account of this.mailAccounts) { for (let account of this.mailAccounts) {
storeData.mailAccounts[account.id.toString()] = { storeData.mailAccounts[account.id.toString()] = {
@ -547,6 +621,120 @@ export class SettingsComponent
this.settingsForm.get('themeColor').patchValue('') this.settingsForm.get('themeColor').patchValue('')
} }
editUser(user: PaperlessUser) {
var modal = this.modalService.open(UserEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.dialogMode = user ? 'edit' : 'create'
modal.componentInstance.object = user
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (newUser) => {
this.toastService.showInfo(
$localize`Saved user "${newUser.username}".`
)
this.usersService.listAll().subscribe((r) => {
this.users = r.results
this.initialize()
})
},
error: (e) => {
this.toastService.showError(
$localize`Error saving user: ${e.toString()}.`
)
},
})
}
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 this user account.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.usersService.delete(user).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted user`)
this.usersService.listAll().subscribe((r) => {
this.users = r.results
this.initialize()
})
},
error: (e) => {
this.toastService.showError(
$localize`Error deleting user: ${e.toString()}.`
)
},
})
})
}
editGroup(group: PaperlessGroup) {
var modal = this.modalService.open(GroupEditDialogComponent, {
backdrop: 'static',
size: 'lg',
})
modal.componentInstance.dialogMode = group ? 'edit' : 'create'
modal.componentInstance.object = group
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (newGroup) => {
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
this.groupsService.listAll().subscribe((r) => {
this.groups = r.results
this.initialize()
})
},
error: (e) => {
this.toastService.showError(
$localize`Error saving group: ${e.toString()}.`
)
},
})
}
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 this user group.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.groupsService.delete(group).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted group`)
this.groupsService.listAll().subscribe((r) => {
this.groups = r.results
this.initialize()
})
},
error: (e) => {
this.toastService.showError(
$localize`Error deleting group: ${e.toString()}.`
)
},
})
})
}
getGroupName(id: number): string {
return this.groups?.find((g) => g.id === id)?.name ?? ''
}
editMailAccount(account: PaperlessMailAccount) { editMailAccount(account: PaperlessMailAccount) {
const modal = this.modalService.open(MailAccountEditDialogComponent, { const modal = this.modalService.open(MailAccountEditDialogComponent, {
backdrop: 'static', backdrop: 'static',

View File

@ -3,6 +3,10 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_STORAGE_PATH } from 'src/app/data/filter-rule-type' import { FILTER_STORAGE_PATH } from 'src/app/data/filter-rule-type'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
@ -18,7 +22,8 @@ export class StoragePathListComponent extends ManagementListComponent<PaperlessS
directoryService: StoragePathService, directoryService: StoragePathService,
modalService: NgbModal, modalService: NgbModal,
toastService: ToastService, toastService: ToastService,
documentListViewService: DocumentListViewService documentListViewService: DocumentListViewService,
permissionsService: PermissionsService
) { ) {
super( super(
directoryService, directoryService,
@ -26,9 +31,11 @@ export class StoragePathListComponent extends ManagementListComponent<PaperlessS
StoragePathEditDialogComponent, StoragePathEditDialogComponent,
toastService, toastService,
documentListViewService, documentListViewService,
permissionsService,
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
$localize`storage path`, $localize`storage path`,
$localize`storage paths`, $localize`storage paths`,
PermissionType.StoragePath,
[ [
{ {
key: 'path', key: 'path',

View File

@ -3,6 +3,10 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { PaperlessTag } from 'src/app/data/paperless-tag' import { PaperlessTag } from 'src/app/data/paperless-tag'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
@ -18,7 +22,8 @@ export class TagListComponent extends ManagementListComponent<PaperlessTag> {
tagService: TagService, tagService: TagService,
modalService: NgbModal, modalService: NgbModal,
toastService: ToastService, toastService: ToastService,
documentListViewService: DocumentListViewService documentListViewService: DocumentListViewService,
permissionsService: PermissionsService
) { ) {
super( super(
tagService, tagService,
@ -26,9 +31,11 @@ export class TagListComponent extends ManagementListComponent<PaperlessTag> {
TagEditDialogComponent, TagEditDialogComponent,
toastService, toastService,
documentListViewService, documentListViewService,
permissionsService,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
$localize`tag`, $localize`tag`,
$localize`tags`, $localize`tags`,
PermissionType.Tag,
[ [
{ {
key: 'color', key: 'color',

View File

@ -5,7 +5,7 @@
<use xlink:href="assets/bootstrap-icons.svg#x"/> <use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>&nbsp;<ng-container i18n>Clear selection</ng-container> </svg>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" [disabled]="tasksService.total === 0"> <button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="tasksService.total === 0">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#check2-all"/> <use xlink:href="assets/bootstrap-icons.svg#check2-all"/>
</svg>&nbsp;<ng-container i18n>{{dismissButtonText}}</ng-container> </svg>&nbsp;<ng-container i18n>{{dismissButtonText}}</ng-container>
@ -75,16 +75,18 @@
</td> </td>
<td scope="row"> <td scope="row">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();"> <button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#check"/> <use xlink:href="assets/bootstrap-icons.svg#check"/>
</svg>&nbsp;<ng-container i18n>Dismiss</ng-container> </svg>&nbsp;<ng-container i18n>Dismiss</ng-container>
</button> </button>
<button *ngIf="task.related_document" class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();"> <ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<svg class="sidebaricon" fill="currentColor"> <button *ngIf="task.related_document" class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
<use xlink:href="assets/bootstrap-icons.svg#file-text"/> <svg class="sidebaricon" fill="currentColor">
</svg>&nbsp;<ng-container i18n>Open Document</ng-container> <use xlink:href="assets/bootstrap-icons.svg#file-text"/>
</button> </svg>&nbsp;<ng-container i18n>Open Document</ng-container>
</button>
</ng-container>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -5,13 +5,17 @@ import { Subject, first } from 'rxjs'
import { PaperlessTask } from 'src/app/data/paperless-task' import { PaperlessTask } from 'src/app/data/paperless-task'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
@Component({ @Component({
selector: 'app-tasks', selector: 'app-tasks',
templateUrl: './tasks.component.html', templateUrl: './tasks.component.html',
styleUrls: ['./tasks.component.scss'], styleUrls: ['./tasks.component.scss'],
}) })
export class TasksComponent implements OnInit, OnDestroy { export class TasksComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
public activeTab: string public activeTab: string
public selectedTasks: Set<number> = new Set() public selectedTasks: Set<number> = new Set()
private unsubscribeNotifer = new Subject() private unsubscribeNotifer = new Subject()
@ -27,7 +31,9 @@ export class TasksComponent implements OnInit, OnDestroy {
public tasksService: TasksService, public tasksService: TasksService,
private modalService: NgbModal, private modalService: NgbModal,
private readonly router: Router private readonly router: Router
) {} ) {
super()
}
ngOnInit() { ngOnInit() {
this.tasksService.reload() this.tasksService.reload()

View File

@ -0,0 +1,9 @@
import {
PermissionAction,
PermissionType,
} from 'src/app/services/permissions.service'
export class ComponentWithPermissions {
public readonly PermissionAction = PermissionAction
public readonly PermissionType = PermissionType
}

View File

@ -1,4 +1,4 @@
import { ObjectWithId } from './object-with-id' import { ObjectWithPermissions } from './object-with-permissions'
export const MATCH_ANY = 1 export const MATCH_ANY = 1
export const MATCH_ALL = 2 export const MATCH_ALL = 2
@ -41,7 +41,7 @@ export const MATCHING_ALGORITHMS = [
}, },
] ]
export interface MatchingModel extends ObjectWithId { export interface MatchingModel extends ObjectWithPermissions {
name?: string name?: string
slug?: string slug?: string

View File

@ -0,0 +1,19 @@
import { ObjectWithId } from './object-with-id'
import { PaperlessUser } from './paperless-user'
export interface PermissionsObject {
view: {
users: Array<number>
groups: Array<number>
}
change: {
users: Array<number>
groups: Array<number>
}
}
export interface ObjectWithPermissions extends ObjectWithId {
owner?: number
permissions?: PermissionsObject
}

View File

@ -1,8 +1,8 @@
import { ObjectWithId } from './object-with-id' import { ObjectWithId } from './object-with-id'
import { User } from './user' import { PaperlessUser } from './paperless-user'
export interface PaperlessDocumentComment extends ObjectWithId { export interface PaperlessDocumentComment extends ObjectWithId {
created?: Date created?: Date
comment?: string comment?: string
user?: User user?: PaperlessUser
} }

View File

@ -1,9 +1,9 @@
import { PaperlessCorrespondent } from './paperless-correspondent' import { PaperlessCorrespondent } from './paperless-correspondent'
import { ObjectWithId } from './object-with-id'
import { PaperlessTag } from './paperless-tag' import { PaperlessTag } from './paperless-tag'
import { PaperlessDocumentType } from './paperless-document-type' import { PaperlessDocumentType } from './paperless-document-type'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { PaperlessStoragePath } from './paperless-storage-path' import { PaperlessStoragePath } from './paperless-storage-path'
import { ObjectWithPermissions } from './object-with-permissions'
export interface SearchHit { export interface SearchHit {
score?: number score?: number
@ -13,7 +13,7 @@ export interface SearchHit {
comment_highlights?: string comment_highlights?: string
} }
export interface PaperlessDocument extends ObjectWithId { export interface PaperlessDocument extends ObjectWithPermissions {
correspondent$?: Observable<PaperlessCorrespondent> correspondent$?: Observable<PaperlessCorrespondent>
correspondent?: number correspondent?: number

View File

@ -0,0 +1,9 @@
import { ObjectWithId } from './object-with-id'
export interface PaperlessGroup extends ObjectWithId {
name?: string
user_count?: number // not implemented yet
permissions?: string[]
}

View File

@ -1,11 +1,8 @@
export interface PaperlessUiSettings { export interface PaperlessUiSettings {
user_id: number user_id: number
username: string username: string
display_name: string
settings: Object settings: Object
permissions: string[]
} }
export interface PaperlessUiSetting { export interface PaperlessUiSetting {

View File

@ -0,0 +1,15 @@
import { PaperlessGroup } from 'src/app/data/paperless-group'
import { ObjectWithId } from './object-with-id'
export interface PaperlessUser extends ObjectWithId {
username?: string
first_name?: string
last_name?: string
date_joined?: Date
is_staff?: boolean
is_active?: boolean
is_superuser?: boolean
groups?: PaperlessGroup[]
user_permissions?: string[]
inherited_permissions?: string[]
}

Some files were not shown because too many files have changed in this diff Show More