Merge branch 'dev' into feature/2396-better-mail-actions

This commit is contained in:
Jonas Winkler 2023-02-19 23:29:52 +01:00
commit 8c5ef111d8
164 changed files with 5748 additions and 976 deletions

View File

@ -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__":

View File

@ -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
View File

@ -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": {

View File

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

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

@ -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:

View File

@ -202,6 +202,39 @@ configured via `PAPERLESS_EMAIL_TASK_CRON` (see [software tweaks](/configuration
You can also submit a document using the REST API, see [POSTing documents](/api#file-uploads)
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

View File

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

View File

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

View File

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

View File

@ -6,8 +6,8 @@
"user": {
"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": ""
}
}
]

View File

@ -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": []
}
]
}

View File

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

View File

@ -1,7 +1,6 @@
{
"user_id": 1,
"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"
]
}

View File

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

View File

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

View File

@ -5,6 +5,14 @@ beforeEach(() => {
fixture: 'ui_settings/settings.json',
}).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',
})

File diff suppressed because it is too large Load Diff

View File

@ -14,8 +14,13 @@ import { DocumentAsnComponent } from './components/document-asn/document-asn.com
import { DirtyFormGuard } from './guards/dirty-form.guard'
import { 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',

View File

@ -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() {

View File

@ -42,6 +42,7 @@ import { CheckComponent } from './components/common/input/check/check.component'
import { PasswordComponent } from './components/common/input/password/password.component'
import { 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,
],

View File

@ -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>&nbsp;<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>&nbsp;{{view.name}}</span>
</a>
</li>
</ul>
</div>
<h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.loading || savedViewService.sidebarViews.length > 0'>
<span i18n>Saved views</span>
<div *ngIf="savedViewService.loading" class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#funnel"/>
</svg><span>&nbsp;{{view.name}}</span>
</a>
</li>
</ul>
<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>&nbsp;{{d.title | documentTitle}}</span>
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16">
<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>&nbsp;{{d.title | documentTitle}}</span>
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</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>&nbsp;<ng-container i18n>Close all</ng-container></span>
</a>
</li>
</ul>
</svg><span>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<ng-container i18n>File Tasks<span *ngIf="tasksService.failedFileTasks.length > 0"><span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></span></ng-container></span>
</a>
</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>&nbsp;<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"/>

View File

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

View File

@ -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',

View File

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

View File

@ -5,6 +5,7 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { 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),
})
}
}

View File

@ -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">

View File

@ -5,6 +5,7 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { 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),
})
}
}

View File

@ -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':

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import {
PaperlessMailAccount,
} 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() {

View File

@ -18,6 +18,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { 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()

View File

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

View File

@ -5,6 +5,7 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { 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),
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown">
<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" />

View File

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

View File

@ -1,4 +1,4 @@
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="toggleItem($event)">
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="toggleItem($event)" [disabled]="disabled">
<div class="selected-icon me-1">
<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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<div class="mb-3 paperless-input-select">
<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>

View File

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

View File

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

View File

@ -1,8 +1,9 @@
<div class="mb-3 paperless-input-select paperless-input-tags">
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled">
<label class="form-label" for="tags" i18n>Tags</label>
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">

View File

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

View File

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

View File

@ -9,13 +9,17 @@ import { PaperlessTag } from 'src/app/data/paperless-tag'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { 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() },

View File

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

View File

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

View File

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

View File

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

View File

@ -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>&nbsp;
<button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive || (isDirty$ | async) !== true || error">Save & next</button>&nbsp;
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || (isDirty$ | async) !== true || error">Save</button>&nbsp;
<ng-container>
<button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>&nbsp;
<button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true || error">Save & next</button>&nbsp;
<button type="submit" class="btn btn-primary" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true || error">Save</button>&nbsp;
</ng-container>
</form>
</div>

View File

@ -35,6 +35,13 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { 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
)
)
}
}

View File

@ -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>&nbsp;<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">&nbsp;<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>&nbsp;<ng-container i18n>Delete</ng-container>

View File

@ -25,6 +25,9 @@ import { saveAs } from 'file-saver'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { 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)
})
}
}

View File

@ -43,7 +43,7 @@
<use xlink:href="assets/bootstrap-icons.svg#diagram-3"/>
</svg>&nbsp;<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>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span>

View File

@ -10,6 +10,7 @@ import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { 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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,10 @@ import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { 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',

View File

@ -3,6 +3,10 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_DOCUMENT_TYPE } from 'src/app/data/filter-rule-type'
import { 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,
[]
)
}

View File

@ -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>&nbsp;<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>&nbsp;<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"/>

View File

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

View File

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