mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge branch 'dev' into feature/2396-better-mail-actions
This commit is contained in:
		
							
								
								
									
										172
									
								
								.github/scripts/cleanup-tags.py
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										172
									
								
								.github/scripts/cleanup-tags.py
									
									
									
									
										vendored
									
									
								
							| @@ -7,6 +7,7 @@ import subprocess | ||||
| from argparse import ArgumentParser | ||||
| from typing import Dict | ||||
| from typing import Final | ||||
| from typing import Iterator | ||||
| from typing import List | ||||
| from typing import Optional | ||||
|  | ||||
| @@ -15,16 +16,17 @@ from github import ContainerPackage | ||||
| from github import GithubBranchApi | ||||
| from github import GithubContainerRegistryApi | ||||
|  | ||||
| import docker | ||||
|  | ||||
| logger = logging.getLogger("cleanup-tags") | ||||
|  | ||||
|  | ||||
| class DockerManifest2: | ||||
| class ImageProperties: | ||||
|     """ | ||||
|     Data class wrapping the Docker Image Manifest Version 2. | ||||
|     Data class wrapping the properties of an entry in the image index | ||||
|     manifests list.  It is NOT an actual image with layers, etc | ||||
|  | ||||
|     See https://docs.docker.com/registry/spec/manifest-v2-2/ | ||||
|     https://docs.docker.com/registry/spec/manifest-v2-2/ | ||||
|     https://github.com/opencontainers/image-spec/blob/main/manifest.md | ||||
|     https://github.com/opencontainers/image-spec/blob/main/descriptor.md | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, data: Dict) -> None: | ||||
| @@ -41,6 +43,45 @@ class DockerManifest2: | ||||
|         self.platform = f"{platform_data_os}/{platform_arch}{platform_variant}" | ||||
|  | ||||
|  | ||||
| class ImageIndex: | ||||
|     """ | ||||
|     Data class wrapping up logic for an OCI Image Index | ||||
|     JSON data.  Primary use is to access the manifests listing | ||||
|  | ||||
|     See https://github.com/opencontainers/image-spec/blob/main/image-index.md | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, package_url: str, tag: str) -> None: | ||||
|         self.qualified_name = f"{package_url}:{tag}" | ||||
|         logger.info(f"Getting image index for {self.qualified_name}") | ||||
|         try: | ||||
|             proc = subprocess.run( | ||||
|                 [ | ||||
|                     shutil.which("docker"), | ||||
|                     "buildx", | ||||
|                     "imagetools", | ||||
|                     "inspect", | ||||
|                     "--raw", | ||||
|                     self.qualified_name, | ||||
|                 ], | ||||
|                 capture_output=True, | ||||
|                 check=True, | ||||
|             ) | ||||
|  | ||||
|             self._data = json.loads(proc.stdout) | ||||
|  | ||||
|         except subprocess.CalledProcessError as e: | ||||
|             logger.error( | ||||
|                 f"Failed to get image index for {self.qualified_name}: {e.stderr}", | ||||
|             ) | ||||
|             raise e | ||||
|  | ||||
|     @property | ||||
|     def image_pointers(self) -> Iterator[ImageProperties]: | ||||
|         for manifest_data in self._data["manifests"]: | ||||
|             yield ImageProperties(manifest_data) | ||||
|  | ||||
|  | ||||
| class RegistryTagsCleaner: | ||||
|     """ | ||||
|     This is the base class for the image registry cleaning.  Given a package | ||||
| @@ -87,7 +128,10 @@ class RegistryTagsCleaner: | ||||
|  | ||||
|     def clean(self): | ||||
|         """ | ||||
|         This method will delete image versions, based on the selected tags to delete | ||||
|         This method will delete image versions, based on the selected tags to delete. | ||||
|         It behaves more like an unlinking than actual deletion.  Removing the tag | ||||
|         simply removes a pointer to an image, but the actual image data remains accessible | ||||
|         if one has the sha256 digest of it. | ||||
|         """ | ||||
|         for tag_to_delete in self.tags_to_delete: | ||||
|             package_version_info = self.all_pkgs_tags_to_version[tag_to_delete] | ||||
| @@ -151,31 +195,17 @@ class RegistryTagsCleaner: | ||||
|  | ||||
|             # Parse manifests to locate digests pointed to | ||||
|             for tag in sorted(self.tags_to_keep): | ||||
|                 full_name = f"ghcr.io/{self.repo_owner}/{self.package_name}:{tag}" | ||||
|                 logger.info(f"Checking manifest for {full_name}") | ||||
|                 # TODO: It would be nice to use RegistryData from docker | ||||
|                 # except the ID doesn't map to anything in the manifest | ||||
|                 try: | ||||
|                     proc = subprocess.run( | ||||
|                         [ | ||||
|                             shutil.which("docker"), | ||||
|                             "buildx", | ||||
|                             "imagetools", | ||||
|                             "inspect", | ||||
|                             "--raw", | ||||
|                             full_name, | ||||
|                         ], | ||||
|                         capture_output=True, | ||||
|                     image_index = ImageIndex( | ||||
|                         f"ghcr.io/{self.repo_owner}/{self.package_name}", | ||||
|                         tag, | ||||
|                     ) | ||||
|  | ||||
|                     manifest_list = json.loads(proc.stdout) | ||||
|                     for manifest_data in manifest_list["manifests"]: | ||||
|                         manifest = DockerManifest2(manifest_data) | ||||
|                     for manifest in image_index.image_pointers: | ||||
|  | ||||
|                         if manifest.digest in untagged_versions: | ||||
|                             logger.info( | ||||
|                                 f"Skipping deletion of {manifest.digest}," | ||||
|                                 f" referred to by {full_name}" | ||||
|                                 f" referred to by {image_index.qualified_name}" | ||||
|                                 f" for {manifest.platform}", | ||||
|                             ) | ||||
|                             del untagged_versions[manifest.digest] | ||||
| @@ -247,64 +277,54 @@ class RegistryTagsCleaner: | ||||
|         # By default, keep anything which is tagged | ||||
|         self.tags_to_keep = list(set(self.all_pkgs_tags_to_version.keys())) | ||||
|  | ||||
|     def check_tags_pull(self): | ||||
|     def check_remaining_tags_valid(self): | ||||
|         """ | ||||
|         This method uses the Docker Python SDK to confirm all tags which were | ||||
|         kept still pull, for all platforms. | ||||
|         Checks the non-deleted tags are still valid.  The assumption is if the | ||||
|         manifest is can be inspected and each image manifest if points to can be | ||||
|         inspected, the image will still pull. | ||||
|  | ||||
|         TODO: This is much slower (although more comprehensive).  Maybe a Pool? | ||||
|         https://github.com/opencontainers/image-spec/blob/main/image-index.md | ||||
|         """ | ||||
|         logger.info("Beginning confirmation step") | ||||
|         client = docker.from_env() | ||||
|         imgs = [] | ||||
|         a_tag_failed = False | ||||
|         for tag in sorted(self.tags_to_keep): | ||||
|             repository = f"ghcr.io/{self.repo_owner}/{self.package_name}" | ||||
|             for arch, variant in [("amd64", None), ("arm64", None), ("arm", "v7")]: | ||||
|                 # From 11.2.0 onwards, qpdf is cross compiled, so there is a single arch, amd64 | ||||
|                 # skip others in this case | ||||
|                 if "qpdf" in self.package_name and arch != "amd64" and tag == "11.2.0": | ||||
|                     continue | ||||
|                 # Skip beta and release candidate tags | ||||
|                 elif "beta" in tag: | ||||
|                     continue | ||||
|  | ||||
|                 # Build the platform name | ||||
|                 if variant is not None: | ||||
|                     platform = f"linux/{arch}/{variant}" | ||||
|                 else: | ||||
|                     platform = f"linux/{arch}" | ||||
|             try: | ||||
|                 image_index = ImageIndex( | ||||
|                     f"ghcr.io/{self.repo_owner}/{self.package_name}", | ||||
|                     tag, | ||||
|                 ) | ||||
|                 for manifest in image_index.image_pointers: | ||||
|                     logger.info(f"Checking {manifest.digest} for {manifest.platform}") | ||||
|  | ||||
|                 try: | ||||
|                     logger.info(f"Pulling {repository}:{tag} for {platform}") | ||||
|                     image = client.images.pull( | ||||
|                         repository=repository, | ||||
|                         tag=tag, | ||||
|                         platform=platform, | ||||
|                     ) | ||||
|                     imgs.append(image) | ||||
|                 except docker.errors.APIError as e: | ||||
|                     logger.error( | ||||
|                         f"Failed to pull {repository}:{tag}: {e}", | ||||
|                     ) | ||||
|                     # This follows the pointer from the index to an actual image, layers and all | ||||
|                     # Note the format is @ | ||||
|                     digest_name = f"ghcr.io/{self.repo_owner}/{self.package_name}@{manifest.digest}" | ||||
|  | ||||
|             # Prevent out of space errors by removing after a few | ||||
|             # pulls | ||||
|             if len(imgs) > 50: | ||||
|                 for image in imgs: | ||||
|                     try: | ||||
|                         client.images.remove(image.id) | ||||
|                     except docker.errors.APIError as e: | ||||
|                         err_str = str(e) | ||||
|                         # Ignore attempts to remove images that are partly shared | ||||
|                         # Ignore images which are somehow gone already | ||||
|                         if ( | ||||
|                             "must be forced" not in err_str | ||||
|                             and "No such image" not in err_str | ||||
|                         ): | ||||
|                             logger.error( | ||||
|                                 f"Remove image ghcr.io/{self.repo_owner}/{self.package_name}:{tag} failed: {e}", | ||||
|                             ) | ||||
|                 imgs = [] | ||||
|  | ||||
|                         subprocess.run( | ||||
|                             [ | ||||
|                                 shutil.which("docker"), | ||||
|                                 "buildx", | ||||
|                                 "imagetools", | ||||
|                                 "inspect", | ||||
|                                 "--raw", | ||||
|                                 digest_name, | ||||
|                             ], | ||||
|                             capture_output=True, | ||||
|                             check=True, | ||||
|                         ) | ||||
|                     except subprocess.CalledProcessError as e: | ||||
|                         logger.error(f"Failed to inspect digest: {e.stderr}") | ||||
|                         a_tag_failed = True | ||||
|             except subprocess.CalledProcessError as e: | ||||
|                 a_tag_failed = True | ||||
|                 logger.error(f"Failed to inspect: {e.stderr}") | ||||
|                 continue | ||||
|  | ||||
|         if a_tag_failed: | ||||
|             raise Exception("At least one image tag failed to inspect") | ||||
|  | ||||
|  | ||||
| class MainImageTagsCleaner(RegistryTagsCleaner): | ||||
| @@ -366,7 +386,7 @@ class MainImageTagsCleaner(RegistryTagsCleaner): | ||||
|  | ||||
| class LibraryTagsCleaner(RegistryTagsCleaner): | ||||
|     """ | ||||
|     Exists for the off change that someday, the installer library images | ||||
|     Exists for the off chance that someday, the installer library images | ||||
|     will need their own logic | ||||
|     """ | ||||
|  | ||||
| @@ -464,7 +484,7 @@ def _main(): | ||||
|  | ||||
|             # Verify remaining tags still pull | ||||
|             if args.is_manifest: | ||||
|                 cleaner.check_tags_pull() | ||||
|                 cleaner.check_remaining_tags_valid() | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|   | ||||
							
								
								
									
										5
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								Pipfile
									
									
									
									
									
								
							| @@ -16,6 +16,7 @@ django-compression-middleware = "*" | ||||
| django-extensions = "*" | ||||
| django-filter = "~=22.1" | ||||
| djangorestframework = "~=3.14" | ||||
| django-ipware = "*" | ||||
| filelock = "*" | ||||
| gunicorn = "*" | ||||
| imap-tools = "*" | ||||
| @@ -61,11 +62,15 @@ bleach = "*" | ||||
| scipy = "==1.8.1" | ||||
| # Newer versions aren't builting yet (see https://www.piwheels.org/project/cryptography/) | ||||
| cryptography = "==38.0.1" | ||||
| django-guardian = "*" | ||||
| djangorestframework-guardian = "*" | ||||
|  | ||||
| # Locked version until https://github.com/django/channels_redis/issues/332 | ||||
| # is resolved | ||||
| channels-redis = "==3.4.1" | ||||
|  | ||||
|  | ||||
|  | ||||
| [dev-packages] | ||||
| coveralls = "*" | ||||
| factory-boy = "*" | ||||
|   | ||||
							
								
								
									
										40
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										40
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +1,7 @@ | ||||
| { | ||||
|     "_meta": { | ||||
|         "hash": { | ||||
|             "sha256": "d70848276d3ac35fa361c15ac2d634344cdb08618790502669eee209fc16fa00" | ||||
|             "sha256": "0e1a26c5e9acb1d745f951f92d00d60272f83406467d90551e558972697b53cd" | ||||
|         }, | ||||
|         "pipfile-spec": 6, | ||||
|         "requires": {}, | ||||
| @@ -313,7 +313,7 @@ | ||||
|                 "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", | ||||
|                 "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" | ||||
|             ], | ||||
|             "markers": "python_full_version >= '3.6.0'", | ||||
|             "markers": "python_version >= '3.6'", | ||||
|             "version": "==2.1.1" | ||||
|         }, | ||||
|         "click": { | ||||
| @@ -329,7 +329,7 @@ | ||||
|                 "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667", | ||||
|                 "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" | ||||
|         }, | ||||
|         "click-plugins": { | ||||
| @@ -472,6 +472,22 @@ | ||||
|             "index": "pypi", | ||||
|             "version": "==22.1" | ||||
|         }, | ||||
|         "django-guardian": { | ||||
|             "hashes": [ | ||||
|                 "sha256:440ca61358427e575323648b25f8384739e54c38b3d655c81d75e0cd0d61b697", | ||||
|                 "sha256:c58a68ae76922d33e6bdc0e69af1892097838de56e93e78a8361090bcd9f89a0" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==2.4.0" | ||||
|         }, | ||||
|         "django-ipware": { | ||||
|             "hashes": [ | ||||
|                 "sha256:602a58325a4808bd19197fef2676a0b2da2df40d0ecf21be414b2ff48c72ad05", | ||||
|                 "sha256:878dbb06a87e25550798e9ef3204ed70a200dd8b15e47dcef848cf08244f04c9" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==4.0.2" | ||||
|         }, | ||||
|         "djangorestframework": { | ||||
|             "hashes": [ | ||||
|                 "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", | ||||
| @@ -480,6 +496,14 @@ | ||||
|             "index": "pypi", | ||||
|             "version": "==3.14.0" | ||||
|         }, | ||||
|         "djangorestframework-guardian": { | ||||
|             "hashes": [ | ||||
|                 "sha256:1883756452d9bfcc2a51fb4e039a6837a8f6697c756447aa83af085749b59330", | ||||
|                 "sha256:3bd3dd6ea58e1bceca5048faf6f8b1a93bb5dcff30ba5eb91b9a0e190a48a0c7" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==0.3.0" | ||||
|         }, | ||||
|         "filelock": { | ||||
|             "hashes": [ | ||||
|                 "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de", | ||||
| @@ -2205,7 +2229,7 @@ | ||||
|                 "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", | ||||
|                 "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" | ||||
|             ], | ||||
|             "markers": "python_full_version >= '3.6.0'", | ||||
|             "markers": "python_version >= '3.6'", | ||||
|             "version": "==2.1.1" | ||||
|         }, | ||||
|         "click": { | ||||
| @@ -2401,7 +2425,7 @@ | ||||
|                 "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874", | ||||
|                 "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.6'", | ||||
|             "markers": "python_full_version >= '3.6.0'", | ||||
|             "version": "==3.3.7" | ||||
|         }, | ||||
|         "markupsafe": { | ||||
| @@ -2455,7 +2479,7 @@ | ||||
|                 "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", | ||||
|                 "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.6'", | ||||
|             "markers": "python_full_version >= '3.6.0'", | ||||
|             "version": "==1.3.4" | ||||
|         }, | ||||
|         "mkdocs": { | ||||
| @@ -2800,7 +2824,7 @@ | ||||
|                 "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", | ||||
|                 "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.6'", | ||||
|             "markers": "python_full_version >= '3.6.0'", | ||||
|             "version": "==0.1" | ||||
|         }, | ||||
|         "regex": { | ||||
| @@ -2987,7 +3011,7 @@ | ||||
|                 "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4", | ||||
|                 "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.6'", | ||||
|             "markers": "python_full_version >= '3.6.0'", | ||||
|             "version": "==20.17.1" | ||||
|         }, | ||||
|         "watchdog": { | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
| version: "3.7" | ||||
| services: | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:7.6 | ||||
|     image: docker.io/gotenberg/gotenberg:7.8 | ||||
|     hostname: gotenberg | ||||
|     container_name: gotenberg | ||||
|     network_mode: host | ||||
|   | ||||
| @@ -83,7 +83,7 @@ services: | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|  | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:7.6 | ||||
|     image: docker.io/gotenberg/gotenberg:7.8 | ||||
|     restart: unless-stopped | ||||
|     # The gotenberg chromium route is used to convert .eml files. We do not | ||||
|     # want to allow external content like tracking pixels or even javascript. | ||||
|   | ||||
| @@ -77,7 +77,7 @@ services: | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|  | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:7.6 | ||||
|     image: docker.io/gotenberg/gotenberg:7.8 | ||||
|     restart: unless-stopped | ||||
|  | ||||
|     # The gotenberg chromium route is used to convert .eml files. We do not | ||||
|   | ||||
| @@ -65,7 +65,7 @@ services: | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|  | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:7.6 | ||||
|     image: docker.io/gotenberg/gotenberg:7.8 | ||||
|     restart: unless-stopped | ||||
|  | ||||
|     # The gotenberg chromium route is used to convert .eml files. We do not | ||||
|   | ||||
| @@ -414,13 +414,6 @@ structure as in the previous example above. | ||||
|     Defining a storage path is optional. If no storage path is defined for a | ||||
|     document, the global `PAPERLESS_FILENAME_FORMAT` is applied. | ||||
|  | ||||
| !!! warning | ||||
|  | ||||
|     If you adjust the format of an existing storage path, old documents | ||||
|     don't get relocated automatically. You need to run the | ||||
|     [document renamer](/administration#renamer) to | ||||
|     adjust their paths. | ||||
|  | ||||
| ## Celery Monitoring {#celery-monitoring} | ||||
|  | ||||
| The monitoring tool | ||||
| @@ -501,3 +494,9 @@ You can also set the default for new tables (this does NOT affect | ||||
| existing tables) with: | ||||
|  | ||||
| `ALTER DATABASE <db_name> CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;` | ||||
|  | ||||
| !!! warning | ||||
|  | ||||
|     Using mariadb version 10.4+ is recommended. Using the `utf8mb3` character set on | ||||
|     an older system may fix issues that can arise while setting up Paperless-ngx but | ||||
|     `utf8mb3` can cause issues with consumption (where `utf8mb4` does not). | ||||
|   | ||||
| @@ -16,6 +16,8 @@ The API provides 7 main endpoints: | ||||
| - `/api/tags/`: Full CRUD support. | ||||
| - `/api/mail_accounts/`: 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 | ||||
| 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. | ||||
| - `tags`: Similar to correspondent. Specify this multiple times to | ||||
|   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 | ||||
| process was started successfully. No additional status information about | ||||
|   | ||||
| @@ -1,12 +1,83 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## paperless-ngx 1.12.1 | ||||
| ## paperless-ngx 1.13.0 | ||||
|  | ||||
| ### Features | ||||
|  | ||||
| - Feature: allow disable warn on close saved view with changes [@shamoon](https://github.com/shamoon) ([#2681](https://github.com/paperless-ngx/paperless-ngx/pull/2681)) | ||||
| - Feature: Add option to enable response compression [@stumpylog](https://github.com/stumpylog) ([#2621](https://github.com/paperless-ngx/paperless-ngx/pull/2621)) | ||||
| - Feature: split documents on ASN barcode [@muued](https://github.com/muued) ([#2554](https://github.com/paperless-ngx/paperless-ngx/pull/2554)) | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| - Fix: Ignore path filtering didn't handle sub directories [@stumpylog](https://github.com/stumpylog) ([#2674](https://github.com/paperless-ngx/paperless-ngx/pull/2674)) | ||||
| - Bugfix: Generation of secret key hangs during install script [@stumpylog](https://github.com/stumpylog) ([#2657](https://github.com/paperless-ngx/paperless-ngx/pull/2657)) | ||||
| - Fix: Remove files produced by barcode splitting when completed [@stumpylog](https://github.com/stumpylog) ([#2648](https://github.com/paperless-ngx/paperless-ngx/pull/2648)) | ||||
| - Fix: add missing storage path placeholders [@shamoon](https://github.com/shamoon) ([#2651](https://github.com/paperless-ngx/paperless-ngx/pull/2651)) | ||||
| - Fix long dropdown contents break document detail column view [@shamoon](https://github.com/shamoon) ([#2638](https://github.com/paperless-ngx/paperless-ngx/pull/2638)) | ||||
| - Fix: tags dropdown should stay closed when removing [@shamoon](https://github.com/shamoon) ([#2625](https://github.com/paperless-ngx/paperless-ngx/pull/2625)) | ||||
| - Bugfix: Configure scheduled tasks to expire after some time [@stumpylog](https://github.com/stumpylog) ([#2614](https://github.com/paperless-ngx/paperless-ngx/pull/2614)) | ||||
| - Bugfix: Limit management list pagination maxSize to 5 [@Kaaybi](https://github.com/Kaaybi) ([#2618](https://github.com/paperless-ngx/paperless-ngx/pull/2618)) | ||||
| - Fix: Don't crash on bad ASNs during indexing [@stumpylog](https://github.com/stumpylog) ([#2586](https://github.com/paperless-ngx/paperless-ngx/pull/2586)) | ||||
| - Fix: Prevent mktime OverflowError except in even more rare caes [@stumpylog](https://github.com/stumpylog) ([#2574](https://github.com/paperless-ngx/paperless-ngx/pull/2574)) | ||||
| - Bugfix: Whoosh relative date queries weren't handling timezones [@stumpylog](https://github.com/stumpylog) ([#2566](https://github.com/paperless-ngx/paperless-ngx/pull/2566)) | ||||
| - Fix importing files with non-ascii names [@Kexogg](https://github.com/Kexogg) ([#2555](https://github.com/paperless-ngx/paperless-ngx/pull/2555)) | ||||
|  | ||||
| ### Documentation | ||||
|  | ||||
| - Chore: update recommended Gotenberg to 7.8, docs note possible incompatibility [@shamoon](https://github.com/shamoon) ([#2608](https://github.com/paperless-ngx/paperless-ngx/pull/2608)) | ||||
| - [Documentation] Add v1.12.2 changelog [@github-actions](https://github.com/github-actions) ([#2553](https://github.com/paperless-ngx/paperless-ngx/pull/2553)) | ||||
|  | ||||
| ### Maintenance | ||||
|  | ||||
| - Chore: Faster Docker image cleanup [@stumpylog](https://github.com/stumpylog) ([#2687](https://github.com/paperless-ngx/paperless-ngx/pull/2687)) | ||||
| - Chore: Remove duplicated folder [@stumpylog](https://github.com/stumpylog) ([#2561](https://github.com/paperless-ngx/paperless-ngx/pull/2561)) | ||||
| - Chore: Switch test coverage to Codecov [@stumpylog](https://github.com/stumpylog) ([#2582](https://github.com/paperless-ngx/paperless-ngx/pull/2582)) | ||||
| - Bump docker/build-push-action from 3 to 4 [@dependabot](https://github.com/dependabot) ([#2576](https://github.com/paperless-ngx/paperless-ngx/pull/2576)) | ||||
| - Chore: Run tests which require convert in the CI [@stumpylog](https://github.com/stumpylog) ([#2570](https://github.com/paperless-ngx/paperless-ngx/pull/2570)) | ||||
|  | ||||
| - Feature: split documents on ASN barcode [@muued](https://github.com/muued) ([#2554](https://github.com/paperless-ngx/paperless-ngx/pull/2554)) | ||||
| - Bugfix: Whoosh relative date queries weren't handling timezones [@stumpylog](https://github.com/stumpylog) ([#2566](https://github.com/paperless-ngx/paperless-ngx/pull/2566)) | ||||
| - Fix importing files with non-ascii names [@Kexogg](https://github.com/Kexogg) ([#2555](https://github.com/paperless-ngx/paperless-ngx/pull/2555)) | ||||
|  | ||||
| ## paperless-ngx 1.12.2 | ||||
|  | ||||
| _Note: Version 1.12.x introduced searching of comments which will work for comments added after the upgrade but a reindex of the search index is required in order to be able to search | ||||
| older comments. The Docker image will automatically perform this reindex, bare metal installations will have to perform this manually, see [the docs](https://docs.paperless-ngx.com/administration/#index)._ | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| - Bugfix: Allow pre-consume scripts to modify incoming file [@stumpylog](https://github.com/stumpylog) ([#2547](https://github.com/paperless-ngx/paperless-ngx/pull/2547)) | ||||
| - Bugfix: Return to page based barcode scanning [@stumpylog](https://github.com/stumpylog) ([#2544](https://github.com/paperless-ngx/paperless-ngx/pull/2544)) | ||||
| - Fix: Try to prevent title debounce overwriting [@shamoon](https://github.com/shamoon) ([#2543](https://github.com/paperless-ngx/paperless-ngx/pull/2543)) | ||||
| - Fix comment search highlight + multi-word search [@shamoon](https://github.com/shamoon) ([#2542](https://github.com/paperless-ngx/paperless-ngx/pull/2542)) | ||||
| - Bugfix: Request PDF/A format from Gotenberg [@stumpylog](https://github.com/stumpylog) ([#2530](https://github.com/paperless-ngx/paperless-ngx/pull/2530)) | ||||
| - Fix: Trigger reindex for pre-existing comments [@shamoon](https://github.com/shamoon) ([#2519](https://github.com/paperless-ngx/paperless-ngx/pull/2519)) | ||||
|  | ||||
| ### Documentation | ||||
|  | ||||
| - Bugfix: Allow pre-consume scripts to modify incoming file [@stumpylog](https://github.com/stumpylog) ([#2547](https://github.com/paperless-ngx/paperless-ngx/pull/2547)) | ||||
| - Fix: Trigger reindex for pre-existing comments [@shamoon](https://github.com/shamoon) ([#2519](https://github.com/paperless-ngx/paperless-ngx/pull/2519)) | ||||
| - Minor updates to development documentation [@clemensrieder](https://github.com/clemensrieder) ([#2474](https://github.com/paperless-ngx/paperless-ngx/pull/2474)) | ||||
| - [Documentation] Add v1.12.1 changelog [@github-actions](https://github.com/github-actions) ([#2515](https://github.com/paperless-ngx/paperless-ngx/pull/2515)) | ||||
|  | ||||
| ### Maintenance | ||||
|  | ||||
| - Chore: Fix tag cleaner to work with attestations [@stumpylog](https://github.com/stumpylog) ([#2532](https://github.com/paperless-ngx/paperless-ngx/pull/2532)) | ||||
| - Chore: Make installers statically versioned [@stumpylog](https://github.com/stumpylog) ([#2517](https://github.com/paperless-ngx/paperless-ngx/pull/2517)) | ||||
|  | ||||
| ### All App Changes | ||||
|  | ||||
| - Bugfix: Allow pre-consume scripts to modify incoming file [@stumpylog](https://github.com/stumpylog) ([#2547](https://github.com/paperless-ngx/paperless-ngx/pull/2547)) | ||||
| - Bugfix: Return to page based barcode scanning [@stumpylog](https://github.com/stumpylog) ([#2544](https://github.com/paperless-ngx/paperless-ngx/pull/2544)) | ||||
| - Fix: Try to prevent title debounce overwriting [@shamoon](https://github.com/shamoon) ([#2543](https://github.com/paperless-ngx/paperless-ngx/pull/2543)) | ||||
| - Fix comment search highlight + multi-word search [@shamoon](https://github.com/shamoon) ([#2542](https://github.com/paperless-ngx/paperless-ngx/pull/2542)) | ||||
| - Bugfix: Request PDF/A format from Gotenberg [@stumpylog](https://github.com/stumpylog) ([#2530](https://github.com/paperless-ngx/paperless-ngx/pull/2530)) | ||||
|  | ||||
| ## paperless-ngx 1.12.1 | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| - Fix: comments not showing in search until after manual reindex in v1.12 [@shamoon](https://github.com/shamoon) ([#2513](https://github.com/paperless-ngx/paperless-ngx/pull/2513)) | ||||
| - Fix: date range search broken in 1.12 [@shamoon](https://github.com/shamoon) ([#2509](https://github.com/paperless-ngx/paperless-ngx/pull/2509)) | ||||
|  | ||||
|   | ||||
| @@ -262,6 +262,14 @@ do CORS calls. Set this to your public domain name. | ||||
|  | ||||
|     Defaults to "<http://localhost:8000>". | ||||
|  | ||||
| `PAPERLESS_TRUSTED_PROXIES=<comma-separated-list>` | ||||
|  | ||||
| : This may be needed to prevent IP address spoofing if you are using e.g. | ||||
| fail2ban with log entries for failed authorization attempts. Value should be | ||||
| IP address(es). | ||||
|  | ||||
|     Defaults to empty string. | ||||
|  | ||||
| `PAPERLESS_FORCE_SCRIPT_NAME=<path>` | ||||
|  | ||||
| : To host paperless under a subpath url like example.com/paperless you | ||||
| @@ -626,7 +634,7 @@ services: | ||||
|     # ... | ||||
|  | ||||
|     gotenberg: | ||||
|       image: gotenberg/gotenberg:7.6 | ||||
|       image: gotenberg/gotenberg:7.8 | ||||
|       restart: unless-stopped | ||||
|       # The gotenberg chromium route is used to convert .eml files. We do not | ||||
|       # want to allow external content like tracking pixels or even javascript. | ||||
| @@ -999,13 +1007,20 @@ within your documents. | ||||
| `PAPERLESS_CONSUMER_IGNORE_PATTERNS=<json>` | ||||
|  | ||||
| : By default, paperless ignores certain files and folders in the | ||||
| consumption directory, such as system files created by the Mac OS. | ||||
| consumption directory, such as system files created by the Mac OS | ||||
| or hidden folders some tools use to store data. | ||||
|  | ||||
|     This can be adjusted by configuring a custom json array with | ||||
|     patterns to exclude. | ||||
|  | ||||
|     For example, `.DS_STORE/*` will ignore any files found in a folder | ||||
|     named `.DS_STORE`, including `.DS_STORE/bar.pdf` and `foo/.DS_STORE/bar.pdf` | ||||
|  | ||||
|     A pattern like `._*` will ignore anything starting with `._`, including: | ||||
|     `._foo.pdf` and `._bar/foo.pdf` | ||||
|  | ||||
|     Defaults to | ||||
|     `[".DS_STORE/*", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini"]`. | ||||
|     `[".DS_STORE/*", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*"]`. | ||||
|  | ||||
| ## Binaries | ||||
|  | ||||
|   | ||||
| @@ -251,7 +251,7 @@ these parts have to be translated separately. | ||||
| - The translated strings need to be placed in the | ||||
|   `src-ui/src/locale/` folder. | ||||
| - In order to extract added or changed strings from the source files, | ||||
|   call `ng xi18n --ivy`. | ||||
|   call `ng extract-i18n`. | ||||
|  | ||||
| Adding new languages requires adding the translated files in the | ||||
| `src-ui/src/locale/` folder and adjusting a couple files. | ||||
|   | ||||
| @@ -708,6 +708,12 @@ below use PostgreSQL, but are applicable to MySQL/MariaDB with the | ||||
|     MySQL also enforces limits on maximum lengths, but does so differently than | ||||
|     PostgreSQL.  It may not be possible to migrate to MySQL due to this. | ||||
|  | ||||
| !!! warning | ||||
|  | ||||
|     Using mariadb version 10.4+ is recommended. Using the `utf8mb3` character set on | ||||
|     an older system may fix issues that can arise while setting up Paperless-ngx but | ||||
|     `utf8mb3` can cause issues with consumption (where `utf8mb4` does not). | ||||
|  | ||||
| 1.  Stop paperless, if it is running. | ||||
|  | ||||
| 2.  Tell paperless to use PostgreSQL: | ||||
|   | ||||
| @@ -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) | ||||
| 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} | ||||
|  | ||||
| Paperless offers a couple tools that help you organize your document | ||||
|   | ||||
| @@ -321,7 +321,7 @@ fi | ||||
| wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/docker-compose.$DOCKER_COMPOSE_VERSION.yml" -O docker-compose.yml | ||||
| wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/.env" -O .env | ||||
|  | ||||
| SECRET_KEY=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w 64 | head -n 1) | ||||
| SECRET_KEY=$(tr --delete --complement 'a-zA-Z0-9' < /dev/urandom 2>/dev/null | head --bytes 64) | ||||
|  | ||||
| DEFAULT_LANGUAGES=("deu eng fra ita spa") | ||||
|  | ||||
|   | ||||
| @@ -2,5 +2,5 @@ | ||||
|  | ||||
| docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:13 | ||||
| docker run -d -p 6379:6379 redis:latest | ||||
| docker run -p 3000:3000 -d gotenberg/gotenberg:7.6 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*" | ||||
| docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*" | ||||
| docker run -p 9998:9998 -d ghcr.io/paperless-ngx/tika:latest | ||||
|   | ||||
							
								
								
									
										68
									
								
								src-ui/cypress/e2e/auth/auth.cy.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src-ui/cypress/e2e/auth/auth.cy.ts
									
									
									
									
									
										Normal 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') | ||||
|   }) | ||||
| }) | ||||
| @@ -6,8 +6,8 @@ | ||||
|         "user": { | ||||
|             "id": 1, | ||||
|             "username": "user2", | ||||
|             "firstname": "", | ||||
|             "lastname": "" | ||||
|             "first_name": "", | ||||
|             "last_name": "" | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -17,8 +17,8 @@ | ||||
|         "user": { | ||||
|             "id": 2, | ||||
|             "username": "user1", | ||||
|             "firstname": "", | ||||
|             "lastname": "" | ||||
|             "first_name": "", | ||||
|             "last_name": "" | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -28,8 +28,8 @@ | ||||
|         "user": { | ||||
|             "id": 2, | ||||
|             "username": "user33", | ||||
|             "firstname": "", | ||||
|             "lastname": "" | ||||
|             "first_name": "", | ||||
|             "last_name": "" | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -39,8 +39,8 @@ | ||||
|         "user": { | ||||
|             "id": 3, | ||||
|             "username": "admin", | ||||
|             "firstname": "", | ||||
|             "lastname": "" | ||||
|             "first_name": "", | ||||
|             "last_name": "" | ||||
|         } | ||||
|     } | ||||
| ] | ||||
|   | ||||
| @@ -14,11 +14,14 @@ | ||||
|                 4 | ||||
|             ], | ||||
|             "created": "2022-03-22T07:24:18Z", | ||||
|             "created_date": "2022-03-22", | ||||
|             "modified": "2022-03-22T07:24:23.264859Z", | ||||
|             "added": "2022-03-22T07:24:22.922631Z", | ||||
|             "archive_serial_number": null, | ||||
|             "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, | ||||
| @@ -29,11 +32,14 @@ | ||||
|             "content": "Test document PDF", | ||||
|             "tags": [], | ||||
|             "created": "2022-03-23T07:24:18Z", | ||||
|             "created_date": "2022-03-23", | ||||
|             "modified": "2022-03-23T07:24:23.264859Z", | ||||
|             "added": "2022-03-23T07:24:22.922631Z", | ||||
|             "archive_serial_number": 12345, | ||||
|             "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, | ||||
| @@ -46,11 +52,14 @@ | ||||
|                 2 | ||||
|             ], | ||||
|             "created": "2022-03-24T07:24:18Z", | ||||
|             "created_date": "2022-03-24", | ||||
|             "modified": "2022-03-24T07:24:23.264859Z", | ||||
|             "added": "2022-03-24T07:24:22.922631Z", | ||||
|             "archive_serial_number": null, | ||||
|             "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, | ||||
| @@ -63,11 +72,14 @@ | ||||
|                 4, 5 | ||||
|             ], | ||||
|             "created": "2022-06-01T07:24:18Z", | ||||
|             "created_date": "2022-06-01", | ||||
|             "modified": "2022-06-01T07:24:23.264859Z", | ||||
|             "added": "2022-06-01T07:24:22.922631Z", | ||||
|             "archive_serial_number": 12347, | ||||
|             "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": [] | ||||
|         } | ||||
|     ] | ||||
| } | ||||
|   | ||||
							
								
								
									
										119
									
								
								src-ui/cypress/fixtures/groups/groups.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								src-ui/cypress/fixtures/groups/groups.json
									
									
									
									
									
										Normal 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" | ||||
|             ] | ||||
|         } | ||||
|     ] | ||||
| } | ||||
| @@ -1,7 +1,6 @@ | ||||
| { | ||||
|     "user_id": 1, | ||||
|     "username": "admin", | ||||
|     "display_name": "Admin", | ||||
|     "settings": { | ||||
|         "language": "", | ||||
|         "bulk_edit": { | ||||
| @@ -30,5 +29,131 @@ | ||||
|             "consumer_failed": 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" | ||||
|     ] | ||||
| } | ||||
|   | ||||
							
								
								
									
										84
									
								
								src-ui/cypress/fixtures/ui_settings/settings_restricted.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src-ui/cypress/fixtures/ui_settings/settings_restricted.json
									
									
									
									
									
										Normal 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" | ||||
|     ] | ||||
| } | ||||
							
								
								
									
										459
									
								
								src-ui/cypress/fixtures/users/users.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										459
									
								
								src-ui/cypress/fixtures/users/users.json
									
									
									
									
									
										Normal 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" | ||||
|             ] | ||||
|         } | ||||
|     ] | ||||
| } | ||||
| @@ -5,6 +5,14 @@ beforeEach(() => { | ||||
|     fixture: 'ui_settings/settings.json', | ||||
|   }).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/', { | ||||
|     fixture: 'remote_version/remote_version.json', | ||||
|   }) | ||||
|   | ||||
							
								
								
									
										1295
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
							
						
						
									
										1295
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -14,8 +14,13 @@ import { DocumentAsnComponent } from './components/document-asn/document-asn.com | ||||
| import { DirtyFormGuard } from './guards/dirty-form.guard' | ||||
| import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | ||||
| import { TasksComponent } from './components/manage/tasks/tasks.component' | ||||
| import { PermissionsGuard } from './guards/permissions.guard' | ||||
| import { DirtyDocGuard } from './guards/dirty-doc.guard' | ||||
| import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' | ||||
| import { | ||||
|   PermissionAction, | ||||
|   PermissionType, | ||||
| } from './services/permissions.service' | ||||
|  | ||||
| const routes: Routes = [ | ||||
|   { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, | ||||
| @@ -29,23 +34,137 @@ const routes: Routes = [ | ||||
|         path: 'documents', | ||||
|         component: DocumentListComponent, | ||||
|         canDeactivate: [DirtySavedViewGuard], | ||||
|         canActivate: [PermissionsGuard], | ||||
|         data: { | ||||
|           requiredPermission: { | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.Document, | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         path: 'view/:id', | ||||
|         component: DocumentListComponent, | ||||
|         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', | ||||
|         component: SettingsComponent, | ||||
|         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', | ||||
|   | ||||
| @@ -9,6 +9,11 @@ import { NgxFileDropEntry } from 'ngx-file-drop' | ||||
| import { UploadDocumentsService } from './services/upload-documents.service' | ||||
| import { TasksService } from './services/tasks.service' | ||||
| import { TourService } from 'ngx-ui-tour-ng-bootstrap' | ||||
| import { | ||||
|   PermissionAction, | ||||
|   PermissionsService, | ||||
|   PermissionType, | ||||
| } from './services/permissions.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-root', | ||||
| @@ -32,7 +37,8 @@ export class AppComponent implements OnInit, OnDestroy { | ||||
|     private uploadDocumentsService: UploadDocumentsService, | ||||
|     private tasksService: TasksService, | ||||
|     public tourService: TourService, | ||||
|     private renderer: Renderer2 | ||||
|     private renderer: Renderer2, | ||||
|     private permissionsService: PermissionsService | ||||
|   ) { | ||||
|     let anyWindow = window as any | ||||
|     anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js' | ||||
| @@ -74,15 +80,28 @@ export class AppComponent implements OnInit, OnDestroy { | ||||
|         if ( | ||||
|           this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS) | ||||
|         ) { | ||||
|           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]) | ||||
|             }, | ||||
|           }) | ||||
|           if ( | ||||
|             this.permissionsService.currentUserCan( | ||||
|               PermissionAction.View, | ||||
|               PermissionType.Document | ||||
|             ) | ||||
|           ) { | ||||
|             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 { | ||||
|     return !this.router.url.includes('dashboard') | ||||
|     return ( | ||||
|       !this.router.url.includes('dashboard') && | ||||
|       this.permissionsService.currentUserCan( | ||||
|         PermissionAction.Add, | ||||
|         PermissionType.Document | ||||
|       ) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   public fileOver() { | ||||
|   | ||||
| @@ -42,6 +42,7 @@ import { CheckComponent } from './components/common/input/check/check.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 { TagsComponent } from './components/common/input/tags/tags.component' | ||||
| import { IfPermissionsDirective } from './directives/if-permissions.directive' | ||||
| import { SortableDirective } from './directives/sortable.directive' | ||||
| import { CookieService } from 'ngx-cookie-service' | ||||
| 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 { DocumentAsnComponent } from './components/document-asn/document-asn.component' | ||||
| import { DocumentCommentsComponent } from './components/document-comments/document-comments.component' | ||||
| import { PermissionsGuard } from './guards/permissions.guard' | ||||
| import { DirtyDocGuard } from './guards/dirty-doc.guard' | ||||
| import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' | ||||
| 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 { TasksComponent } from './components/manage/tasks/tasks.component' | ||||
| import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' | ||||
| import { UserEditDialogComponent } from './components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component' | ||||
| import { GroupEditDialogComponent } from './components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component' | ||||
| 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 { 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 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 localeTr from '@angular/common/locales/tr' | ||||
| 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(localeBe) | ||||
| @@ -165,6 +176,7 @@ function initializeApp(settings: SettingsService) { | ||||
|     PasswordComponent, | ||||
|     SaveViewConfigDialogComponent, | ||||
|     TagsComponent, | ||||
|     IfPermissionsDirective, | ||||
|     SortableDirective, | ||||
|     SavedViewWidgetComponent, | ||||
|     StatisticsWidgetComponent, | ||||
| @@ -186,8 +198,17 @@ function initializeApp(settings: SettingsService) { | ||||
|     DocumentAsnComponent, | ||||
|     DocumentCommentsComponent, | ||||
|     TasksComponent, | ||||
|     UserEditDialogComponent, | ||||
|     GroupEditDialogComponent, | ||||
|     PermissionsSelectComponent, | ||||
|     MailAccountEditDialogComponent, | ||||
|     MailRuleEditDialogComponent, | ||||
|     PermissionsUserComponent, | ||||
|     PermissionsGroupComponent, | ||||
|     IfOwnerDirective, | ||||
|     IfObjectPermissionsDirective, | ||||
|     PermissionsDialogComponent, | ||||
|     PermissionsFormComponent, | ||||
|   ], | ||||
|   imports: [ | ||||
|     BrowserModule, | ||||
| @@ -225,6 +246,7 @@ function initializeApp(settings: SettingsService) { | ||||
|     DocumentTitlePipe, | ||||
|     { provide: NgbDateAdapter, useClass: ISODateAdapter }, | ||||
|     { provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter }, | ||||
|     PermissionsGuard, | ||||
|     DirtyDocGuard, | ||||
|     DirtySavedViewGuard, | ||||
|   ], | ||||
|   | ||||
| @@ -10,7 +10,7 @@ | ||||
|     </svg> | ||||
|     <span class="ms-2" [class.visually-hidden]="slimSidebarEnabled" i18n="app title">Paperless-ngx</span> | ||||
|   </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"> | ||||
|       <svg width="1em" height="1em" fill="currentColor"> | ||||
|         <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> | ||||
|           <div class="dropdown-divider"></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"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#gear"/> | ||||
|           </svg><ng-container i18n>Settings</ng-container> | ||||
| @@ -72,7 +72,7 @@ | ||||
|               </svg><span> <ng-container i18n>Dashboard</ng-container></span> | ||||
|             </a> | ||||
|           </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"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#files"/> | ||||
| @@ -80,79 +80,82 @@ | ||||
|             </a> | ||||
|           </li> | ||||
|         </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> {{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'> | ||||
|           <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> {{view.name}}</span> | ||||
|             </a> | ||||
|           </li> | ||||
|         </ul> | ||||
|  | ||||
|         <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'> | ||||
|           <span i18n>Open documents</span> | ||||
|         </h6> | ||||
|         <ul class="nav flex-column mb-2"> | ||||
|           <li class="nav-item w-100" *ngFor='let d of openDocuments'> | ||||
|             <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"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#file-text"/> | ||||
|               </svg><span> {{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"> | ||||
|         <div *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> | ||||
|           <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'> | ||||
|             <span i18n>Open documents</span> | ||||
|           </h6> | ||||
|           <ul class="nav flex-column mb-2"> | ||||
|             <li class="nav-item w-100" *ngFor='let d of openDocuments'> | ||||
|               <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"> | ||||
|                   <use xlink:href="assets/bootstrap-icons.svg#file-text"/> | ||||
|                 </svg><span> {{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"/> | ||||
|                   </svg> | ||||
|                 </span> | ||||
|               </a> | ||||
|             </li> | ||||
|             <li class="nav-item w-100" *ngIf="openDocuments.length >= 1"> | ||||
|               <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> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item w-100" *ngIf="openDocuments.length >= 1"> | ||||
|             <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> <ng-container i18n>Close all</ng-container></span> | ||||
|             </a> | ||||
|           </li> | ||||
|         </ul> | ||||
|                 </svg><span> <ng-container i18n>Close all</ng-container></span> | ||||
|               </a> | ||||
|             </li> | ||||
|           </ul> | ||||
|       </div> | ||||
|  | ||||
|         <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted"> | ||||
|           <span i18n>Manage</span> | ||||
|         </h6> | ||||
|         <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"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#person"/> | ||||
|               </svg><span> <ng-container i18n>Correspondents</ng-container></span> | ||||
|             </a> | ||||
|           </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"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#tags"/> | ||||
|               </svg><span> <ng-container i18n>Tags</ng-container></span> | ||||
|             </a> | ||||
|           </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"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#hash"/> | ||||
|               </svg><span> <ng-container i18n>Document types</ng-container></span> | ||||
|             </a> | ||||
|           </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"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#folder"/> | ||||
|               </svg><span> <ng-container i18n>Storage paths</ng-container></span> | ||||
|             </a> | ||||
|           </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"> | ||||
|               <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"> | ||||
| @@ -160,14 +163,14 @@ | ||||
|               </svg><span> <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> | ||||
|           </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"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#text-left"/> | ||||
|               </svg><span> <ng-container i18n>Logs</ng-container></span> | ||||
|             </a> | ||||
|           </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"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#gear"/> | ||||
|   | ||||
| @@ -26,13 +26,17 @@ import { TasksService } from 'src/app/services/tasks.service' | ||||
| import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-app-frame', | ||||
|   templateUrl: './app-frame.component.html', | ||||
|   styleUrls: ['./app-frame.component.scss'], | ||||
| }) | ||||
| export class AppFrameComponent implements OnInit, ComponentCanDeactivate { | ||||
| export class AppFrameComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, ComponentCanDeactivate | ||||
| { | ||||
|   constructor( | ||||
|     public router: Router, | ||||
|     private activatedRoute: ActivatedRoute, | ||||
| @@ -44,7 +48,9 @@ export class AppFrameComponent implements OnInit, ComponentCanDeactivate { | ||||
|     public settingsService: SettingsService, | ||||
|     public tasksService: TasksService, | ||||
|     private readonly toastService: ToastService | ||||
|   ) {} | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) { | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { Component, EventEmitter, Input, Output } from '@angular/core' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { interval, Subject, switchMap, take } from 'rxjs' | ||||
| import { interval, Subject, take } from 'rxjs' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-confirm-dialog', | ||||
|   | ||||
| @@ -5,10 +5,16 @@ | ||||
|     </button> | ||||
|   </div> | ||||
|   <div class="modal-body"> | ||||
|  | ||||
|     <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-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> | ||||
|  | ||||
|     <div *appIfOwner="object"> | ||||
|       <app-permissions-form [users]="users" accordion="true" formControlName="permissions_form"></app-permissions-form> | ||||
|     </div> | ||||
|  | ||||
|   </div> | ||||
|   <div class="modal-footer"> | ||||
|     <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> | ||||
|   | ||||
| @@ -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 { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' | ||||
| import { CorrespondentService } from 'src/app/services/rest/correspondent.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-correspondent-edit-dialog', | ||||
| @@ -12,8 +13,12 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic | ||||
|   styleUrls: ['./correspondent-edit-dialog.component.scss'], | ||||
| }) | ||||
| export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> { | ||||
|   constructor(service: CorrespondentService, activeModal: NgbActiveModal) { | ||||
|     super(service, activeModal) | ||||
|   constructor( | ||||
|     service: CorrespondentService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService | ||||
|   ) { | ||||
|     super(service, activeModal, userService) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
| @@ -30,6 +35,7 @@ export class CorrespondentEditDialogComponent extends EditDialogComponent<Paperl | ||||
|       matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM), | ||||
|       match: new FormControl(''), | ||||
|       is_insensitive: new FormControl(true), | ||||
|       permissions_form: new FormControl(null), | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -6,10 +6,16 @@ | ||||
|     </div> | ||||
|     <div class="modal-body"> | ||||
|  | ||||
|       <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-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 class="col"> | ||||
|         <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-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 class="modal-footer"> | ||||
|   | ||||
| @@ -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 { PaperlessDocumentType } from 'src/app/data/paperless-document-type' | ||||
| import { DocumentTypeService } from 'src/app/services/rest/document-type.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
|  | ||||
| @Component({ | ||||
|   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'], | ||||
| }) | ||||
| export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> { | ||||
|   constructor(service: DocumentTypeService, activeModal: NgbActiveModal) { | ||||
|     super(service, activeModal) | ||||
|   constructor( | ||||
|     service: DocumentTypeService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService | ||||
|   ) { | ||||
|     super(service, activeModal, userService) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
| @@ -30,6 +35,7 @@ export class DocumentTypeEditDialogComponent extends EditDialogComponent<Paperle | ||||
|       matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM), | ||||
|       match: new FormControl(''), | ||||
|       is_insensitive: new FormControl(true), | ||||
|       permissions_form: new FormControl(null), | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,17 +4,25 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { Observable } from 'rxjs' | ||||
| import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model' | ||||
| 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 { UserService } from 'src/app/services/rest/user.service' | ||||
| import { PermissionsFormObject } from '../input/permissions/permissions-form/permissions-form.component' | ||||
|  | ||||
| @Directive() | ||||
| export abstract class EditDialogComponent<T extends ObjectWithId> | ||||
|   implements OnInit | ||||
| export abstract class EditDialogComponent< | ||||
|   T extends ObjectWithPermissions | ObjectWithId | ||||
| > implements OnInit | ||||
| { | ||||
|   constructor( | ||||
|     private service: AbstractPaperlessService<T>, | ||||
|     private activeModal: NgbActiveModal | ||||
|     private activeModal: NgbActiveModal, | ||||
|     private userService: UserService | ||||
|   ) {} | ||||
|  | ||||
|   users: PaperlessUser[] | ||||
|  | ||||
|   @Input() | ||||
|   dialogMode: string = 'create' | ||||
|  | ||||
| @@ -36,6 +44,14 @@ export abstract class EditDialogComponent<T extends ObjectWithId> | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     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) | ||||
|     } | ||||
|  | ||||
| @@ -43,6 +59,8 @@ export abstract class EditDialogComponent<T extends ObjectWithId> | ||||
|     setTimeout(() => { | ||||
|       this.closeEnabled = true | ||||
|     }) | ||||
|  | ||||
|     this.userService.listAll().subscribe((r) => (this.users = r.results)) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
| @@ -77,10 +95,16 @@ export abstract class EditDialogComponent<T extends ObjectWithId> | ||||
|   } | ||||
|  | ||||
|   save() { | ||||
|     var newObject = Object.assign( | ||||
|       Object.assign({}, this.object), | ||||
|       this.objectForm.value | ||||
|     ) | ||||
|     const formValues = Object.assign({}, this.objectForm.value) | ||||
|     const permissionsObject: PermissionsFormObject = | ||||
|       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> | ||||
|     switch (this.dialogMode) { | ||||
|       case 'create': | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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), | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| @@ -7,6 +7,7 @@ import { | ||||
|   PaperlessMailAccount, | ||||
| } from 'src/app/data/paperless-mail-account' | ||||
| import { MailAccountService } from 'src/app/services/rest/mail-account.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
|  | ||||
| const IMAP_SECURITY_OPTIONS = [ | ||||
|   { id: IMAPSecurity.None, name: $localize`No encryption` }, | ||||
| @@ -20,8 +21,12 @@ const IMAP_SECURITY_OPTIONS = [ | ||||
|   styleUrls: ['./mail-account-edit-dialog.component.scss'], | ||||
| }) | ||||
| export class MailAccountEditDialogComponent extends EditDialogComponent<PaperlessMailAccount> { | ||||
|   constructor(service: MailAccountService, activeModal: NgbActiveModal) { | ||||
|     super(service, activeModal) | ||||
|   constructor( | ||||
|     service: MailAccountService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService | ||||
|   ) { | ||||
|     super(service, activeModal, userService) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
|   | ||||
| @@ -18,6 +18,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic | ||||
| import { DocumentTypeService } from 'src/app/services/rest/document-type.service' | ||||
| import { MailAccountService } from 'src/app/services/rest/mail-account.service' | ||||
| import { MailRuleService } from 'src/app/services/rest/mail-rule.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
|  | ||||
| const ATTACHMENT_TYPE_OPTIONS = [ | ||||
|   { | ||||
| @@ -113,9 +114,10 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<PaperlessMa | ||||
|     activeModal: NgbActiveModal, | ||||
|     accountService: MailAccountService, | ||||
|     correspondentService: CorrespondentService, | ||||
|     documentTypeService: DocumentTypeService | ||||
|     documentTypeService: DocumentTypeService, | ||||
|     userService: UserService | ||||
|   ) { | ||||
|     super(service, activeModal) | ||||
|     super(service, activeModal, userService) | ||||
|  | ||||
|     accountService | ||||
|       .listAll() | ||||
|   | ||||
| @@ -6,16 +6,16 @@ | ||||
|   </div> | ||||
|   <div class="modal-body"> | ||||
|  | ||||
|     <p *ngIf="this.dialogMode === 'edit'" i18n> | ||||
|       <em>Note that editing a path does not apply changes to stored files until you have run the 'document_renamer' utility. See the <a target="_blank" href="https://docs.paperless-ngx.com/administration/#renamer">documentation</a>.</em> | ||||
|     </p> | ||||
|  | ||||
|     <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> | ||||
|     <app-input-text i18n-title title="Path" formControlName="path" [error]="error?.path" [hint]="pathHint"></app-input-text> | ||||
|     <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-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 class="modal-footer"> | ||||
|     <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> | ||||
|   | ||||
| @@ -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 { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
|  | ||||
| @Component({ | ||||
|   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'], | ||||
| }) | ||||
| export class StoragePathEditDialogComponent extends EditDialogComponent<PaperlessStoragePath> { | ||||
|   constructor(service: StoragePathService, activeModal: NgbActiveModal) { | ||||
|     super(service, activeModal) | ||||
|   constructor( | ||||
|     service: StoragePathService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService | ||||
|   ) { | ||||
|     super(service, activeModal, userService) | ||||
|   } | ||||
|  | ||||
|   get pathHint() { | ||||
| @@ -41,6 +46,7 @@ export class StoragePathEditDialogComponent extends EditDialogComponent<Paperles | ||||
|       matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM), | ||||
|       match: new FormControl(''), | ||||
|       is_insensitive: new FormControl(true), | ||||
|       permissions_form: new FormControl(null), | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -13,6 +13,11 @@ | ||||
|       <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-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 class="modal-footer"> | ||||
|       <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { PaperlessTag } from 'src/app/data/paperless-tag' | ||||
| import { TagService } from 'src/app/services/rest/tag.service' | ||||
| import { randomColor } from 'src/app/utils/color' | ||||
| import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
|  | ||||
| @Component({ | ||||
|   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'], | ||||
| }) | ||||
| export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> { | ||||
|   constructor(service: TagService, activeModal: NgbActiveModal) { | ||||
|     super(service, activeModal) | ||||
|   constructor( | ||||
|     service: TagService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService | ||||
|   ) { | ||||
|     super(service, activeModal, userService) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
| @@ -33,6 +38,7 @@ export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> { | ||||
|       matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM), | ||||
|       match: new FormControl(''), | ||||
|       is_insensitive: new FormControl(true), | ||||
|       permissions_form: new FormControl(null), | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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 | ||||
|       ) | ||||
|   } | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| <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"> | ||||
|       <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" /> | ||||
|     </svg> | ||||
| @@ -25,10 +25,10 @@ | ||||
|       </div> | ||||
|       <div *ngIf="selectionModel.items" class="items"> | ||||
|         <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> | ||||
|       </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> | ||||
|         <svg width="1.5em" height="1em" viewBox="0 0 16 16" fill="currentColor"> | ||||
|           <use xlink:href="assets/bootstrap-icons.svg#arrow-right" /> | ||||
|   | ||||
| @@ -317,6 +317,9 @@ export class FilterableDropdownComponent { | ||||
|   @Input() | ||||
|   applyOnClose = false | ||||
|  | ||||
|   @Input() | ||||
|   disabled = false | ||||
|  | ||||
|   @Output() | ||||
|   apply = new EventEmitter<ChangedItems>() | ||||
|  | ||||
|   | ||||
| @@ -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"> | ||||
|     <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"> | ||||
|   | ||||
| @@ -23,6 +23,9 @@ export class ToggleableDropdownButtonComponent { | ||||
|   @Input() | ||||
|   count: number | ||||
|  | ||||
|   @Input() | ||||
|   disabled: boolean = false | ||||
|  | ||||
|   @Output() | ||||
|   toggle = new EventEmitter() | ||||
|  | ||||
|   | ||||
| @@ -3,8 +3,8 @@ | ||||
|   <div class="input-group" [class.is-invalid]="error"> | ||||
|     <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)" | ||||
|           name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel"> | ||||
|     <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button"> | ||||
|           name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled"> | ||||
|     <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"> | ||||
|         <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> | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| <div class="mb-3"> | ||||
|   <label class="form-label" [for]="inputId">{{title}}</label> | ||||
|   <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"> | ||||
|     <button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="value">+1</button> | ||||
|     <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]="disabled">+1</button> | ||||
|   </div> | ||||
|   <div class="invalid-feedback"> | ||||
|     {{error}} | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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 }) | ||||
|   } | ||||
| } | ||||
| @@ -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> | ||||
| @@ -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)) | ||||
|   } | ||||
| } | ||||
| @@ -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> | ||||
| @@ -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 | ||||
|           )) | ||||
|       ) | ||||
|   } | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| <div class="mb-3 paperless-input-select"> | ||||
|   <label class="form-label" [for]="inputId">{{title}}</label> | ||||
| <div class="mb-3 paperless-input-select" [class.disabled]="disabled"> | ||||
|   <label *ngIf="title" class="form-label" [for]="inputId">{{title}}</label> | ||||
|     <div [class.input-group]="allowCreateNew"> | ||||
|       <ng-select name="inputId" [(ngModel)]="value" | ||||
|         [disabled]="disabled" | ||||
| @@ -11,7 +11,8 @@ | ||||
|         addTagText="Add item" | ||||
|         i18n-addTagText="Used for both types, correspondents, storage paths" | ||||
|         [placeholder]="placeholder" | ||||
|         bindLabel="name" | ||||
|         [multiple]="multiple" | ||||
|         [bindLabel]="bindLabel" | ||||
|         bindValue="id" | ||||
|         (change)="onChange(value)" | ||||
|         (search)="onSearch($event)" | ||||
| @@ -19,7 +20,7 @@ | ||||
|         (clear)="clearLastSearchTerm()" | ||||
|         (blur)="onBlur()"> | ||||
|       </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"> | ||||
|           <use xlink:href="assets/bootstrap-icons.svg#plus" /> | ||||
|         </svg> | ||||
|   | ||||
| @@ -1 +1,14 @@ | ||||
| // 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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -44,6 +44,12 @@ export class SelectComponent extends AbstractInputComponent<number> { | ||||
|   @Input() | ||||
|   placeholder: string | ||||
|  | ||||
|   @Input() | ||||
|   multiple: boolean = false | ||||
|  | ||||
|   @Input() | ||||
|   bindLabel: string = 'name' | ||||
|  | ||||
|   @Output() | ||||
|   createNew = new EventEmitter<string>() | ||||
|  | ||||
|   | ||||
| @@ -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> | ||||
|  | ||||
|   <div class="input-group flex-nowrap"> | ||||
|     <ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value" | ||||
|       [disabled]="disabled" | ||||
|       [multiple]="true" | ||||
|       [closeOnSelect]="false" | ||||
|       [clearSearchOnAdd]="true" | ||||
| @@ -31,7 +32,7 @@ | ||||
|       </ng-template> | ||||
|     </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"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#plus" /> | ||||
|       </svg> | ||||
|   | ||||
| @@ -10,3 +10,17 @@ | ||||
| .tag-wrap-delete { | ||||
|   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; | ||||
|       } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -74,6 +74,8 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|   } | ||||
|  | ||||
|   removeTag(event: PointerEvent, id: number) { | ||||
|     if (this.disabled) return | ||||
|  | ||||
|     // prevent opening dropdown | ||||
|     event.stopImmediatePropagation() | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <div class="mb-3"> | ||||
|   <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> | ||||
|   <div class="invalid-feedback"> | ||||
|     {{error}} | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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() | ||||
|   } | ||||
| } | ||||
| @@ -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> | ||||
| @@ -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 | ||||
|   } | ||||
| } | ||||
| @@ -9,3 +9,14 @@ | ||||
| .toast:not(.show) { | ||||
|   display: block; // this corrects an ng-bootstrap bug that prevented animations | ||||
| } | ||||
|  | ||||
| ::ng-deep .toast.error .toast-header { | ||||
|   background-color: hsla(350, 79%, 40%, 0.8); // bg-danger | ||||
|   border-color: black; | ||||
| } | ||||
|  | ||||
| ::ng-deep .toast.error .toast-body { | ||||
|   background-color: hsla(350, 79%, 40%, 0.8); // bg-danger | ||||
|   border-bottom-left-radius: inherit; | ||||
|   border-bottom-right-radius: inherit; | ||||
| } | ||||
|   | ||||
| @@ -28,12 +28,14 @@ | ||||
|  | ||||
|     <app-welcome-widget *ngIf="settingsService.offerTour()" tourAnchor="tour.dashboard"></app-welcome-widget> | ||||
|  | ||||
|     <ng-container *ngFor="let v of savedViewService.dashboardViews; first as isFirst"> | ||||
|       <app-saved-view-widget *ngIf="isFirst; else noTour" [savedView]="v" tourAnchor="tour.dashboard"></app-saved-view-widget> | ||||
|       <ng-template #noTour> | ||||
|         <app-saved-view-widget [savedView]="v"></app-saved-view-widget> | ||||
|       </ng-template> | ||||
|     </ng-container> | ||||
|     <div *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> | ||||
|       <ng-container *ngFor="let v of savedViewService.dashboardViews; first as isFirst"> | ||||
|         <app-saved-view-widget *ngIf="isFirst; else noTour" [savedView]="v" tourAnchor="tour.dashboard"></app-saved-view-widget> | ||||
|         <ng-template #noTour> | ||||
|           <app-saved-view-widget [savedView]="v"></app-saved-view-widget> | ||||
|         </ng-template> | ||||
|       </ng-container> | ||||
|     </div> | ||||
|  | ||||
|   </div> | ||||
|   <div class="col-lg-4"> | ||||
|   | ||||
| @@ -1,17 +1,20 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-dashboard', | ||||
|   templateUrl: './dashboard.component.html', | ||||
|   styleUrls: ['./dashboard.component.scss'], | ||||
| }) | ||||
| export class DashboardComponent { | ||||
| export class DashboardComponent extends ComponentWithPermissions { | ||||
|   constructor( | ||||
|     public savedViewService: SavedViewService, | ||||
|     public settingsService: SettingsService | ||||
|   ) {} | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   get subtitle() { | ||||
|     if (this.settingsService.displayName) { | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <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"> | ||||
| @@ -10,7 +10,7 @@ | ||||
|         <th scope="col" i18n>Title</th> | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|     <tbody *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> | ||||
|       <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.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></app-tag></a></td> | ||||
|   | ||||
| @@ -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 { OpenDocumentsService } from 'src/app/services/open-documents.service' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-saved-view-widget', | ||||
|   templateUrl: './saved-view-widget.component.html', | ||||
|   styleUrls: ['./saved-view-widget.component.scss'], | ||||
| }) | ||||
| export class SavedViewWidgetComponent implements OnInit, OnDestroy { | ||||
| export class SavedViewWidgetComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   loading: boolean = true | ||||
|  | ||||
|   constructor( | ||||
| @@ -24,7 +28,9 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy { | ||||
|     private list: DocumentListViewService, | ||||
|     private consumerStatusService: ConsumerStatusService, | ||||
|     public openDocumentsService: OpenDocumentsService | ||||
|   ) {} | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   savedView: PaperlessSavedView | ||||
| @@ -74,6 +80,7 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   clickTag(tag: PaperlessTag, event: MouseEvent) { | ||||
|     event.preventDefault() | ||||
|     event.stopImmediatePropagation() | ||||
|  | ||||
|     this.list.quickFilter([ | ||||
|       { rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() }, | ||||
|   | ||||
| @@ -9,7 +9,7 @@ | ||||
|     </a> | ||||
|   </div> | ||||
|   <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)" | ||||
|         (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 | ||||
| @@ -40,13 +40,15 @@ | ||||
|     <h6 class="alert-heading">{{status.filename}}</h6> | ||||
|     <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> | ||||
|     <div *ngIf="isFinished(status)"> | ||||
|       <button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)"> | ||||
|         <small i18n>Open document</small> | ||||
|         <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"> | ||||
|           <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> | ||||
|       </button> | ||||
|     <div *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> | ||||
|       <div *ngIf="isFinished(status)"> | ||||
|         <button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)"> | ||||
|           <small i18n>Open document</small> | ||||
|           <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"> | ||||
|             <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> | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </ngb-alert> | ||||
| </ng-template> | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { NgxFileDropEntry } from 'ngx-file-drop' | ||||
| import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' | ||||
| import { | ||||
|   ConsumerStatusService, | ||||
|   FileStatus, | ||||
| @@ -14,13 +15,15 @@ const MAX_ALERTS = 5 | ||||
|   templateUrl: './upload-file-widget.component.html', | ||||
|   styleUrls: ['./upload-file-widget.component.scss'], | ||||
| }) | ||||
| export class UploadFileWidgetComponent { | ||||
| export class UploadFileWidgetComponent extends ComponentWithPermissions { | ||||
|   alertsExpanded = false | ||||
|  | ||||
|   constructor( | ||||
|     private consumerStatusService: ConsumerStatusService, | ||||
|     private uploadDocumentsService: UploadDocumentsService | ||||
|   ) {} | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   getStatus() { | ||||
|     return this.consumerStatusService.getConsumerStatus().slice(0, MAX_ALERTS) | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <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"> | ||||
|             <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> | ||||
| @@ -18,7 +18,7 @@ | ||||
|         </div> | ||||
|         <div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center"> | ||||
|             <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"> | ||||
|                     <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|                 </svg> | ||||
|   | ||||
| @@ -4,13 +4,14 @@ import { PaperlessDocumentComment } from 'src/app/data/paperless-document-commen | ||||
| import { FormControl, FormGroup } from '@angular/forms' | ||||
| import { first } from 'rxjs/operators' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-document-comments', | ||||
|   templateUrl: './document-comments.component.html', | ||||
|   styleUrls: ['./document-comments.component.scss'], | ||||
| }) | ||||
| export class DocumentCommentsComponent { | ||||
| export class DocumentCommentsComponent extends ComponentWithPermissions { | ||||
|   commentForm: FormGroup = new FormGroup({ | ||||
|     newComment: new FormControl(''), | ||||
|   }) | ||||
| @@ -32,7 +33,9 @@ export class DocumentCommentsComponent { | ||||
|   constructor( | ||||
|     private commentsService: DocumentCommentsService, | ||||
|     private toastService: ToastService | ||||
|   ) {} | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   update(): void { | ||||
|     this.networkActive = true | ||||
| @@ -89,8 +92,8 @@ export class DocumentCommentsComponent { | ||||
|   displayName(comment: PaperlessDocumentComment): string { | ||||
|     if (!comment.user) return '' | ||||
|     let nameComponents = [] | ||||
|     if (comment.user.firstname) nameComponents.unshift(comment.user.firstname) | ||||
|     if (comment.user.lastname) nameComponents.unshift(comment.user.lastname) | ||||
|     if (comment.user.first_name) nameComponents.unshift(comment.user.first_name) | ||||
|     if (comment.user.last_name) nameComponents.unshift(comment.user.last_name) | ||||
|     if (comment.user.username) { | ||||
|       if (nameComponents.length > 0) | ||||
|         nameComponents.push(`(${comment.user.username})`) | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
|       <div class="input-group-text" i18n>of {{previewNumPages}}</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"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|         </svg><span class="d-none d-lg-inline ps-1" i18n>Delete</span> | ||||
| @@ -20,7 +20,7 @@ | ||||
|         </a> | ||||
|  | ||||
|         <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> | ||||
|                 <a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a> | ||||
|             </div> | ||||
| @@ -28,7 +28,7 @@ | ||||
|  | ||||
|     </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"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" /> | ||||
|         </svg><span class="d-none d-lg-inline ps-1" i18n>Redo OCR</span> | ||||
| @@ -148,7 +148,7 @@ | ||||
|                 </li> | ||||
|  | ||||
|                 <li [ngbNavItem]="4" class="d-md-none"> | ||||
|                     <a ngbNavLink>Preview</a> | ||||
|                     <a ngbNavLink i18n>Preview</a> | ||||
|                     <ng-template ngbNavContent *ngIf="!pdfPreview.offsetParent"> | ||||
|                         <div class="position-relative"> | ||||
|                             <ng-container *ngIf="getContentType() === 'application/pdf'"> | ||||
| @@ -170,19 +170,31 @@ | ||||
|                         </div> | ||||
|                     </ng-template> | ||||
|                 </li> | ||||
|  | ||||
|                 <li [ngbNavItem]="5" *ngIf="commentsEnabled"> | ||||
|                     <a ngbNavLink i18n>Comments</a> | ||||
|                     <ng-template ngbNavContent> | ||||
|                         <app-document-comments [documentId]="documentId"></app-document-comments> | ||||
|                     </ng-template> | ||||
|                 </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> | ||||
|  | ||||
|             <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>  | ||||
|             <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive || (isDirty$ | async) !== true || error">Save & next</button>  | ||||
|             <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || (isDirty$ | async) !== true || error">Save</button>  | ||||
|             <ng-container> | ||||
|                 <button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>  | ||||
|                 <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true || error">Save & next</button>  | ||||
|                 <button type="submit" class="btn btn-primary" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true || error">Save</button>  | ||||
|             </ng-container> | ||||
|         </form> | ||||
|     </div> | ||||
|  | ||||
|   | ||||
| @@ -35,6 +35,13 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| 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 { 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({ | ||||
|   selector: 'app-document-detail', | ||||
| @@ -58,6 +65,7 @@ export class DocumentDetailComponent | ||||
|   document: PaperlessDocument | ||||
|   metadata: PaperlessDocumentMetadata | ||||
|   suggestions: PaperlessDocumentSuggestions | ||||
|   users: PaperlessUser[] | ||||
|  | ||||
|   title: string | ||||
|   titleSubject: Subject<string> = new Subject() | ||||
| @@ -78,6 +86,7 @@ export class DocumentDetailComponent | ||||
|     storage_path: new FormControl(), | ||||
|     archive_serial_number: new FormControl(), | ||||
|     tags: new FormControl([]), | ||||
|     permissions_form: new FormControl(null), | ||||
|   }) | ||||
|  | ||||
|   previewCurrentPage: number = 1 | ||||
| @@ -106,6 +115,9 @@ export class DocumentDetailComponent | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   PermissionAction = PermissionAction | ||||
|   PermissionType = PermissionType | ||||
|  | ||||
|   constructor( | ||||
|     private documentsService: DocumentService, | ||||
|     private route: ActivatedRoute, | ||||
| @@ -118,7 +130,9 @@ export class DocumentDetailComponent | ||||
|     private documentTitlePipe: DocumentTitlePipe, | ||||
|     private toastService: ToastService, | ||||
|     private settings: SettingsService, | ||||
|     private storagePathService: StoragePathService | ||||
|     private storagePathService: StoragePathService, | ||||
|     private permissionsService: PermissionsService, | ||||
|     private userService: UserService | ||||
|   ) {} | ||||
|  | ||||
|   titleKeyUp(event) { | ||||
| @@ -147,7 +161,13 @@ export class DocumentDetailComponent | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         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 | ||||
| @@ -165,6 +185,11 @@ export class DocumentDetailComponent | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.storagePaths = result.results)) | ||||
|  | ||||
|     this.userService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.users = result.results)) | ||||
|  | ||||
|     this.route.paramMap | ||||
|       .pipe( | ||||
|         takeUntil(this.unsubscribeNotifier), | ||||
| @@ -232,6 +257,10 @@ export class DocumentDetailComponent | ||||
|             storage_path: doc.storage_path, | ||||
|             archive_serial_number: doc.archive_serial_number, | ||||
|             tags: [...doc.tags], | ||||
|             permissions_form: { | ||||
|               owner: doc.owner, | ||||
|               set_permissions: doc.permissions, | ||||
|             }, | ||||
|           }) | ||||
|  | ||||
|           this.isDirty$ = dirtyCheck( | ||||
| @@ -272,6 +301,9 @@ export class DocumentDetailComponent | ||||
|         }, | ||||
|         error: (error) => { | ||||
|           this.metadata = null | ||||
|           this.toastService.showError( | ||||
|             $localize`Error retrieving metadata` + ': ' + error.toString() | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|     this.documentsService | ||||
| @@ -283,10 +315,20 @@ export class DocumentDetailComponent | ||||
|         }, | ||||
|         error: (error) => { | ||||
|           this.suggestions = null | ||||
|           this.toastService.showError( | ||||
|             $localize`Error retrieving suggestions` + ': ' + error.toString() | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|     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) { | ||||
| @@ -378,7 +420,7 @@ export class DocumentDetailComponent | ||||
|       .update(this.document) | ||||
|       .pipe(first()) | ||||
|       .subscribe({ | ||||
|         next: (result) => { | ||||
|         next: () => { | ||||
|           this.close() | ||||
|           this.networkActive = false | ||||
|           this.error = null | ||||
| @@ -386,6 +428,11 @@ export class DocumentDetailComponent | ||||
|         error: (error) => { | ||||
|           this.networkActive = false | ||||
|           this.error = error.error | ||||
|           this.toastService.showError( | ||||
|             $localize`Error saving document` + | ||||
|               ': ' + | ||||
|               (error.message ?? error.toString()) | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
| @@ -425,6 +472,11 @@ export class DocumentDetailComponent | ||||
|         error: (error) => { | ||||
|           this.networkActive = false | ||||
|           this.error = error.error | ||||
|           this.toastService.showError( | ||||
|             $localize`Error saving document` + | ||||
|               ': ' + | ||||
|               (error.message ?? error.toString()) | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
| @@ -564,6 +616,36 @@ export class DocumentDetailComponent | ||||
|   } | ||||
|  | ||||
|   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 | ||||
|       ) | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -23,11 +23,12 @@ | ||||
|   </div> | ||||
|   <div class="w-100 d-xl-none"></div> | ||||
|   <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> | ||||
|       <app-filterable-dropdown class="me-2 me-md-3" title="Tags" icon="tag-fill" i18n-title | ||||
|         filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||
|         [items]="tags" | ||||
|         [disabled]="!userCanEditAll" | ||||
|         [editing]="true" | ||||
|         [multiple]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
| @@ -38,6 +39,7 @@ | ||||
|       <app-filterable-dropdown class="me-2 me-md-3" title="Correspondent" icon="person-fill" i18n-title | ||||
|         filterPlaceholder="Filter correspondents" i18n-filterPlaceholder | ||||
|         [items]="correspondents" | ||||
|         [disabled]="!userCanEditAll" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         (opened)="openCorrespondentDropdown()" | ||||
| @@ -47,6 +49,7 @@ | ||||
|       <app-filterable-dropdown class="me-2 me-md-3" title="Document type" icon="file-earmark-fill" i18n-title | ||||
|         filterPlaceholder="Filter document types" i18n-filterPlaceholder | ||||
|         [items]="documentTypes" | ||||
|         [disabled]="!userCanEditAll" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         (opened)="openDocumentTypeDropdown()" | ||||
| @@ -56,6 +59,7 @@ | ||||
|       <app-filterable-dropdown class="me-2 me-md-3" title="Storage path" icon="folder-fill" i18n-title | ||||
|         filterPlaceholder="Filter storage paths" i18n-filterPlaceholder | ||||
|         [items]="storagePaths" | ||||
|         [disabled]="!userCanEditAll" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         (opened)="openStoragePathDropdown()" | ||||
| @@ -65,7 +69,14 @@ | ||||
|     </div> | ||||
|   </div> | ||||
|   <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> <ng-container i18n>Permissions</ng-container> | ||||
|       </button> | ||||
|  | ||||
|       <div ngbDropdown class="me-2 d-flex"> | ||||
|         <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> | ||||
|           <svg class="toolbaricon" fill="currentColor"> | ||||
| @@ -74,7 +85,7 @@ | ||||
|           <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||
|         </button> | ||||
|         <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> | ||||
| @@ -120,7 +131,7 @@ | ||||
|     </div> | ||||
|  | ||||
|     <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"> | ||||
|           <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|         </svg> <ng-container i18n>Delete</ng-container> | ||||
|   | ||||
| @@ -25,6 +25,9 @@ import { saveAs } from 'file-saver' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| 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 { first, Subject, takeUntil } from 'rxjs' | ||||
|  | ||||
| @@ -33,7 +36,10 @@ import { first, Subject, takeUntil } from 'rxjs' | ||||
|   templateUrl: './bulk-editor.component.html', | ||||
|   styleUrls: ['./bulk-editor.component.scss'], | ||||
| }) | ||||
| export class BulkEditorComponent implements OnInit, OnDestroy { | ||||
| export class BulkEditorComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   tags: PaperlessTag[] | ||||
|   correspondents: PaperlessCorrespondent[] | ||||
|   documentTypes: PaperlessDocumentType[] | ||||
| @@ -63,8 +69,11 @@ export class BulkEditorComponent implements OnInit, OnDestroy { | ||||
|     private openDocumentService: OpenDocumentsService, | ||||
|     private settings: SettingsService, | ||||
|     private toastService: ToastService, | ||||
|     private storagePathService: StoragePathService | ||||
|   ) {} | ||||
|     private storagePathService: StoragePathService, | ||||
|     private permissionService: PermissionsService | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   applyOnClose: boolean = this.settings.get( | ||||
|     SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE | ||||
| @@ -73,6 +82,25 @@ export class BulkEditorComponent implements OnInit, OnDestroy { | ||||
|     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() { | ||||
|     this.tagService | ||||
|       .listAll() | ||||
| @@ -463,4 +491,14 @@ export class BulkEditorComponent implements OnInit, OnDestroy { | ||||
|         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) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -43,7 +43,7 @@ | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#diagram-3"/> | ||||
|               </svg> <span class="d-none d-md-inline" i18n>More like this</span> | ||||
|             </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"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#pencil"/> | ||||
|               </svg> <span class="d-none d-md-inline" i18n>Edit</span> | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
| import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-document-card-large', | ||||
| @@ -19,11 +20,13 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
|     '../popover-preview/popover-preview.scss', | ||||
|   ], | ||||
| }) | ||||
| export class DocumentCardLargeComponent { | ||||
| export class DocumentCardLargeComponent extends ComponentWithPermissions { | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private settingsService: SettingsService | ||||
|   ) {} | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   selected = false | ||||
|   | ||||
| @@ -67,7 +67,7 @@ | ||||
|       </div> | ||||
|       <div class="d-flex justify-content-between align-items-center"> | ||||
|         <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"> | ||||
|               <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> | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
| import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-document-card-small', | ||||
| @@ -20,11 +21,13 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
|     '../popover-preview/popover-preview.scss', | ||||
|   ], | ||||
| }) | ||||
| export class DocumentCardSmallComponent { | ||||
| export class DocumentCardSmallComponent extends ComponentWithPermissions { | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private settingsService: SettingsService | ||||
|   ) {} | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   selected = false | ||||
|   | ||||
| @@ -59,7 +59,7 @@ | ||||
|     </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> | ||||
|       <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"> | ||||
| @@ -72,8 +72,10 @@ | ||||
|         <div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div> | ||||
|       </ng-container> | ||||
|  | ||||
|       <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.activeSavedViewId" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button> | ||||
|       <button ngbDropdownItem (click)="saveViewConfigAs()" i18n>Save as...</button> | ||||
|       <div *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }"> | ||||
|         <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> | ||||
|  | ||||
|   | ||||
| @@ -30,6 +30,7 @@ import { | ||||
| } from 'src/app/services/rest/document.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.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 { 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', | ||||
|   styleUrls: ['./document-list.component.scss'], | ||||
| }) | ||||
| export class DocumentListComponent implements OnInit, OnDestroy { | ||||
| export class DocumentListComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   constructor( | ||||
|     public list: DocumentListViewService, | ||||
|     public savedViewService: SavedViewService, | ||||
| @@ -48,7 +52,9 @@ export class DocumentListComponent implements OnInit, OnDestroy { | ||||
|     private modalService: NgbModal, | ||||
|     private consumerStatusService: ConsumerStatusService, | ||||
|     public openDocumentsService: OpenDocumentsService | ||||
|   ) {} | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   @ViewChild('filterEditor') | ||||
|   private filterEditor: FilterEditorComponent | ||||
|   | ||||
| @@ -4,6 +4,10 @@ import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type' | ||||
| import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| 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 { ToastService } from 'src/app/services/toast.service' | ||||
| 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, | ||||
|     toastService: ToastService, | ||||
|     documentListViewService: DocumentListViewService, | ||||
|     permissionsService: PermissionsService, | ||||
|     private datePipe: CustomDatePipe | ||||
|   ) { | ||||
|     super( | ||||
| @@ -29,9 +34,11 @@ export class CorrespondentListComponent extends ManagementListComponent<Paperles | ||||
|       CorrespondentEditDialogComponent, | ||||
|       toastService, | ||||
|       documentListViewService, | ||||
|       permissionsService, | ||||
|       FILTER_CORRESPONDENT, | ||||
|       $localize`correspondent`, | ||||
|       $localize`correspondents`, | ||||
|       PermissionType.Correspondent, | ||||
|       [ | ||||
|         { | ||||
|           key: 'last_correspondence', | ||||
|   | ||||
| @@ -3,6 +3,10 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { FILTER_DOCUMENT_TYPE } from 'src/app/data/filter-rule-type' | ||||
| import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' | ||||
| 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 { ToastService } from 'src/app/services/toast.service' | ||||
| 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, | ||||
|     modalService: NgbModal, | ||||
|     toastService: ToastService, | ||||
|     documentListViewService: DocumentListViewService | ||||
|     documentListViewService: DocumentListViewService, | ||||
|     permissionsService: PermissionsService | ||||
|   ) { | ||||
|     super( | ||||
|       documentTypeService, | ||||
| @@ -26,9 +31,11 @@ export class DocumentTypeListComponent extends ManagementListComponent<Paperless | ||||
|       DocumentTypeEditDialogComponent, | ||||
|       toastService, | ||||
|       documentListViewService, | ||||
|       permissionsService, | ||||
|       FILTER_DOCUMENT_TYPE, | ||||
|       $localize`document type`, | ||||
|       $localize`document types`, | ||||
|       PermissionType.DocumentType, | ||||
|       [] | ||||
|     ) | ||||
|   } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <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> | ||||
|  | ||||
| <div class="row"> | ||||
| @@ -41,24 +41,24 @@ | ||||
|               </svg> | ||||
|             </button> | ||||
|             <div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> | ||||
|               <button (click)="filterDocuments(object)" ngbDropdownItem i18n>Filter Documents</button> | ||||
|               <button (click)="openEditDialog(object)" ngbDropdownItem i18n>Edit</button> | ||||
|               <button class="text-danger" (click)="openDeleteDialog(object)" ngbDropdownItem i18n>Delete</button> | ||||
|               <button (click)="filterDocuments(object)" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents</button> | ||||
|               <button (click)="openEditDialog(object)" *appIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button> | ||||
|               <button class="text-danger" (click)="openDeleteDialog(object)" *appIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <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"> | ||||
|               <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> <ng-container i18n>Documents</ng-container> | ||||
|           </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"> | ||||
|               <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> <ng-container i18n>Edit</ng-container> | ||||
|           </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"> | ||||
|               <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"/> | ||||
|   | ||||
| @@ -14,14 +14,20 @@ import { | ||||
|   MATCH_AUTO, | ||||
| } from 'src/app/data/matching-model' | ||||
| import { ObjectWithId } from 'src/app/data/object-with-id' | ||||
| import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' | ||||
| import { | ||||
|   SortableDirective, | ||||
|   SortEvent, | ||||
| } from 'src/app/directives/sortable.directive' | ||||
| 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 { ToastService } from 'src/app/services/toast.service' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | ||||
|  | ||||
| export interface ManagementListColumn { | ||||
|   key: string | ||||
| @@ -35,6 +41,7 @@ export interface ManagementListColumn { | ||||
|  | ||||
| @Directive() | ||||
| export abstract class ManagementListComponent<T extends ObjectWithId> | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   constructor( | ||||
| @@ -43,11 +50,15 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | ||||
|     private editDialogComponent: any, | ||||
|     private toastService: ToastService, | ||||
|     private documentListViewService: DocumentListViewService, | ||||
|     private permissionsService: PermissionsService, | ||||
|     protected filterRuleType: number, | ||||
|     public typeName: string, | ||||
|     public typeNamePlural: string, | ||||
|     public permissionType: PermissionType, | ||||
|     public extraColumns: ManagementListColumn[] | ||||
|   ) {} | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   @ViewChildren(SortableDirective) headers: QueryList<SortableDirective> | ||||
|  | ||||
| @@ -209,4 +220,15 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | ||||
|   onNameFilterKeyUp(event: KeyboardEvent) { | ||||
|     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 | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <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> | ||||
|   <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> | ||||
|       <svg class="sidebaricon ms-1" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#arrow-up-right"/> | ||||
| @@ -189,6 +189,14 @@ | ||||
|       <a ngbNavLink i18n>Saved views</a> | ||||
|       <ng-template ngbNavContent> | ||||
|  | ||||
|         <h4 i18n>Settings</h4> | ||||
|         <div class="row mb-3"> | ||||
|           <div class="offset-md-3 col"> | ||||
|             <app-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></app-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <h4 i18n>Views</h4> | ||||
|         <div formGroupName="savedViews"> | ||||
|  | ||||
|             <div *ngFor="let view of savedViews" [formGroupName]="view.id" class="row"> | ||||
| @@ -211,7 +219,7 @@ | ||||
|  | ||||
|               <div class="mb-2 col-auto"> | ||||
|                 <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> | ||||
|  | ||||
| @@ -227,80 +235,84 @@ | ||||
|       </ng-template> | ||||
|     </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> | ||||
|       <ng-template ngbNavContent> | ||||
|  | ||||
|         <ng-container *ngIf="mailAccounts && mailRules"> | ||||
|           <h4> | ||||
|             <ng-container i18n>Mail accounts</ng-container> | ||||
|             <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()"> | ||||
|               <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|               </svg> | ||||
|               <ng-container i18n>Add Account</ng-container> | ||||
|             </button> | ||||
|           </h4> | ||||
|           <ul class="list-group" formGroupName="mailAccounts"> | ||||
|           <ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"> | ||||
|             <h4> | ||||
|               <ng-container i18n>Mail accounts</ng-container> | ||||
|               <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()"> | ||||
|                 <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|                   <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|                 </svg> | ||||
|                 <ng-container i18n>Add Account</ng-container> | ||||
|               </button> | ||||
|             </h4> | ||||
|             <ul class="list-group" formGroupName="mailAccounts"> | ||||
|  | ||||
|               <li class="list-group-item"> | ||||
|                 <div class="row"> | ||||
|                   <div class="col" i18n>Name</div> | ||||
|                   <div class="col" i18n>Server</div> | ||||
|                   <div class="col" i18n>Actions</div> | ||||
|                 </div> | ||||
|               </li> | ||||
|                 <li class="list-group-item"> | ||||
|                   <div class="row"> | ||||
|                     <div class="col" i18n>Name</div> | ||||
|                     <div class="col" i18n>Server</div> | ||||
|                     <div class="col" i18n>Actions</div> | ||||
|                   </div> | ||||
|                 </li> | ||||
|  | ||||
|               <li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id"> | ||||
|                 <div class="row"> | ||||
|                   <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)">{{account.name}}</button></div> | ||||
|                   <div class="col d-flex align-items-center">{{account.imap_server}}</div> | ||||
|                   <div class="col"> | ||||
|                     <div class="btn-group"> | ||||
|                       <button 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> | ||||
|                 <li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id"> | ||||
|                   <div class="row"> | ||||
|                     <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)">{{account.name}}</button></div> | ||||
|                     <div class="col d-flex align-items-center">{{account.imap_server}}</div> | ||||
|                     <div class="col"> | ||||
|                       <div class="btn-group"> | ||||
|                         <button *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" 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-outline-danger" type="button" (click)="deleteMailAccount(account)" i18n>Delete</button> | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </div> | ||||
|               </li> | ||||
|                 </li> | ||||
|  | ||||
|               <div *ngIf="mailAccounts.length === 0" i18n>No mail accounts defined.</div> | ||||
|           </ul> | ||||
|                 <div *ngIf="mailAccounts.length === 0" i18n>No mail accounts defined.</div> | ||||
|             </ul> | ||||
|           </ng-container> | ||||
|  | ||||
|           <h4 class="mt-4"> | ||||
|             <ng-container i18n>Mail rules</ng-container> | ||||
|             <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()"> | ||||
|               <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|               </svg> | ||||
|               <ng-container i18n>Add Rule</ng-container> | ||||
|             </button> | ||||
|           </h4> | ||||
|           <ul class="list-group" formGroupName="mailRules"> | ||||
|           <ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }"> | ||||
|             <h4 class="mt-4"> | ||||
|               <ng-container i18n>Mail rules</ng-container> | ||||
|               <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()"> | ||||
|                 <svg class="sidebaricon me-1" fill="currentColor"> | ||||
|                   <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||
|                 </svg> | ||||
|                 <ng-container i18n>Add Rule</ng-container> | ||||
|               </button> | ||||
|             </h4> | ||||
|             <ul class="list-group" formGroupName="mailRules"> | ||||
|  | ||||
|               <li class="list-group-item"> | ||||
|                 <div class="row"> | ||||
|                   <div class="col" i18n>Name</div> | ||||
|                   <div class="col" i18n>Account</div> | ||||
|                   <div class="col" i18n>Actions</div> | ||||
|                 </div> | ||||
|               </li> | ||||
|                 <li class="list-group-item"> | ||||
|                   <div class="row"> | ||||
|                     <div class="col" i18n>Name</div> | ||||
|                     <div class="col" i18n>Account</div> | ||||
|                     <div class="col" i18n>Actions</div> | ||||
|                   </div> | ||||
|                 </li> | ||||
|  | ||||
|               <li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id"> | ||||
|                 <div class="row"> | ||||
|                   <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)">{{rule.name}}</button></div> | ||||
|                   <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> | ||||
|                   <div class="col"> | ||||
|                     <div class="btn-group"> | ||||
|                       <button 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> | ||||
|                 <li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id"> | ||||
|                   <div class="row"> | ||||
|                     <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)">{{rule.name}}</button></div> | ||||
|                     <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> | ||||
|                     <div class="col"> | ||||
|                       <div class="btn-group"> | ||||
|                         <button *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" 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-outline-danger" type="button" (click)="deleteMailRule(rule)" i18n>Delete</button> | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </div> | ||||
|               </li> | ||||
|                 </li> | ||||
|  | ||||
|               <div *ngIf="mailRules.length === 0" i18n>No mail rules defined.</div> | ||||
|           </ul> | ||||
|                 <div *ngIf="mailRules.length === 0" i18n>No mail rules defined.</div> | ||||
|             </ul> | ||||
|           </ng-container> | ||||
|         </ng-container> | ||||
|  | ||||
|         <div *ngIf="!mailAccounts || !mailRules"> | ||||
| @@ -310,9 +322,95 @@ | ||||
|  | ||||
|       </ng-template> | ||||
|     </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> | ||||
|  | ||||
|   <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> | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	 Jonas Winkler
					Jonas Winkler