From 3292a0e7ccd92ed132d08e90941f8f80333aa339 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Mar 2026 08:49:52 -0700 Subject: [PATCH 1/8] Fix: validate date CF value in serializer (#12410) --- src/documents/serialisers.py | 2 + src/documents/tests/test_api_custom_fields.py | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index ea8cc70b6..011045ea8 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -967,6 +967,8 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): getattr(request, "user", None) if request is not None else None, doc_ids, ) + elif field.data_type == CustomField.FieldDataType.DATE: + data["value"] = serializers.DateField().to_internal_value(data["value"]) return data diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py index 998bc445a..9a775dc9d 100644 --- a/src/documents/tests/test_api_custom_fields.py +++ b/src/documents/tests/test_api_custom_fields.py @@ -1425,3 +1425,41 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) results = response.data["results"] self.assertEqual(results[0]["document_count"], 0) + + def test_patch_document_invalid_date_custom_field_returns_validation_error(self): + """ + GIVEN: + - A date custom field + - A document + WHEN: + - Patching the document with a date string in the wrong format + THEN: + - HTTP 400 is returned instead of an internal server error + - No custom field instance is created + """ + cf_date = CustomField.objects.create( + name="datefield", + data_type=CustomField.FieldDataType.DATE, + ) + doc = Document.objects.create( + title="Doc", + checksum="123", + mime_type="application/pdf", + ) + + response = self.client.patch( + f"/api/documents/{doc.pk}/", + { + "custom_fields": [ + { + "field": cf_date.pk, + "value": "10.03.2026", + }, + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("custom_fields", response.data) + self.assertEqual(CustomFieldInstance.objects.count(), 0) From 3cfe9fa2a84720486d6e47f49b86e2bceec7c3e1 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:15:18 -0700 Subject: [PATCH 2/8] Fixhancement: default mail -created correspondent matching to exact (#12414) --- src/paperless_mail/mail.py | 1 + src/paperless_mail/tests/test_mail.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py index edb266c51..ef13a01e0 100644 --- a/src/paperless_mail/mail.py +++ b/src/paperless_mail/mail.py @@ -472,6 +472,7 @@ class MailAccountHandler(LoggingMixin): name=name, defaults={ "match": name, + "matching_algorithm": Correspondent.MATCH_LITERAL, }, )[0] except DatabaseError as e: diff --git a/src/paperless_mail/tests/test_mail.py b/src/paperless_mail/tests/test_mail.py index f8ab14bdd..68582b5f7 100644 --- a/src/paperless_mail/tests/test_mail.py +++ b/src/paperless_mail/tests/test_mail.py @@ -448,7 +448,7 @@ class TestMail( c = handler._get_correspondent(message, rule) self.assertIsNotNone(c) self.assertEqual(c.name, "someone@somewhere.com") - self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_ANY) + self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_LITERAL) self.assertEqual(c.match, "someone@somewhere.com") c = handler._get_correspondent(message2, rule) self.assertIsNotNone(c) From 66c5c46913977deb08fd61dcb85f04a57b745d61 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 26 Mar 2026 06:16:14 -0700 Subject: [PATCH 3/8] Fix: add fallback ordering for documents by id after created (#12440) --- src/documents/tests/test_api_documents.py | 37 +++++++++++++++++++++++ src/documents/views.py | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index baa0ffc56..d38091347 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -1087,6 +1087,43 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): self.assertEqual(len(response.data["all"]), 50) self.assertCountEqual(response.data["all"], [d.id for d in docs]) + def test_default_ordering_uses_id_as_tiebreaker(self): + """ + GIVEN: + - Documents sharing the same created date + WHEN: + - API request for documents without an explicit ordering + THEN: + - Results are correctly ordered by created > id + """ + older_doc = Document.objects.create( + checksum="older", + content="older", + created=date(2024, 1, 1), + ) + first_same_date_doc = Document.objects.create( + checksum="same-date-1", + content="same-date-1", + created=date(2024, 1, 2), + ) + second_same_date_doc = Document.objects.create( + checksum="same-date-2", + content="same-date-2", + created=date(2024, 1, 2), + ) + + response = self.client.get("/api/documents/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [result["id"] for result in response.data["results"]], + [ + second_same_date_doc.id, + first_same_date_doc.id, + older_doc.id, + ], + ) + def test_statistics(self): doc1 = Document.objects.create( title="none1", diff --git a/src/documents/views.py b/src/documents/views.py index 5985c17a8..b7600bb6e 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -785,7 +785,7 @@ class DocumentViewSet( def get_queryset(self): return ( Document.objects.distinct() - .order_by("-created") + .order_by("-created", "-id") .annotate(num_notes=Count("notes")) .select_related("correspondent", "storage_path", "document_type", "owner") .prefetch_related("tags", "custom_fields", "notes") From 501cdd92d276451384cec4f944a878d1289aa4ea Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:34:13 -0700 Subject: [PATCH 4/8] Fix: limit share link viewset actions (#12461) --- src/documents/tests/test_api_documents.py | 52 +++++++++++++++++++++++ src/documents/views.py | 10 ++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index d38091347..352ce7810 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -2990,6 +2990,58 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED) self.assertEqual(create_resp.data["document"], doc.pk) + def test_share_link_update_methods_not_allowed(self): + """ + GIVEN: + - An existing share link + WHEN: + - PUT and PATCH requests are made to its detail endpoint + THEN: + - The API rejects them with 405 and the link is unchanged + """ + doc = Document.objects.create( + title="test", + mime_type="application/pdf", + content="share link content", + ) + expiration = timezone.now() + timedelta(days=7) + create_resp = self.client.post( + "/api/share_links/", + data={ + "document": doc.pk, + "expiration": expiration.isoformat(), + "file_version": ShareLink.FileVersion.ORIGINAL, + }, + format="json", + ) + self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED) + share_link_id = create_resp.data["id"] + + patch_resp = self.client.patch( + f"/api/share_links/{share_link_id}/", + data={ + "expiration": None, + "file_version": ShareLink.FileVersion.ARCHIVE, + }, + format="json", + ) + self.assertEqual(patch_resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + put_resp = self.client.put( + f"/api/share_links/{share_link_id}/", + data={ + "document": doc.pk, + "expiration": None, + "file_version": ShareLink.FileVersion.ARCHIVE, + }, + format="json", + ) + self.assertEqual(put_resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + share_link = ShareLink.objects.get(pk=share_link_id) + self.assertEqual(share_link.file_version, ShareLink.FileVersion.ORIGINAL) + self.assertIsNotNone(share_link.expiration) + def test_next_asn(self): """ GIVEN: diff --git a/src/documents/views.py b/src/documents/views.py index b7600bb6e..732fe2232 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -76,6 +76,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.filters import OrderingFilter from rest_framework.filters import SearchFilter from rest_framework.generics import GenericAPIView +from rest_framework.mixins import CreateModelMixin from rest_framework.mixins import DestroyModelMixin from rest_framework.mixins import ListModelMixin from rest_framework.mixins import RetrieveModelMixin @@ -2702,7 +2703,14 @@ class TasksViewSet(ReadOnlyModelViewSet): ) -class ShareLinkViewSet(ModelViewSet, PassUserMixin): +class ShareLinkViewSet( + PassUserMixin, + CreateModelMixin, + RetrieveModelMixin, + DestroyModelMixin, + ListModelMixin, + GenericViewSet, +): model = ShareLink queryset = ShareLink.objects.all() From 2f5bcdf66e47f1c52e10de9b05b51a89c01f4278 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:54:37 -0700 Subject: [PATCH 5/8] Fix: dont defer tag change application in workflows (#12478) --- src/documents/signals/handlers.py | 5 +- src/documents/tests/test_workflows.py | 118 ++++++++++++++++++++++++++ src/documents/workflows/mutations.py | 14 +-- 3 files changed, 123 insertions(+), 14 deletions(-) diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index f8d670e30..4ff5a2b33 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -818,7 +818,6 @@ def run_workflows( # Refresh this so the matching data is fresh and instance fields are re-freshed # Otherwise, this instance might be behind and overwrite the work another process did document.refresh_from_db() - doc_tag_ids = list(document.tags.values_list("pk", flat=True)) if matching.document_matches_workflow(document, workflow, trigger_type): action: WorkflowAction @@ -836,14 +835,13 @@ def run_workflows( apply_assignment_to_document( action, document, - doc_tag_ids, logging_group, ) elif action.type == WorkflowAction.WorkflowActionType.REMOVAL: if use_overrides and overrides: apply_removal_to_overrides(action, overrides) else: - apply_removal_to_document(action, document, doc_tag_ids) + apply_removal_to_document(action, document) elif action.type == WorkflowAction.WorkflowActionType.EMAIL: context = build_workflow_action_context(document, overrides) execute_email_action( @@ -886,7 +884,6 @@ def run_workflows( "modified", ], ) - document.tags.set(doc_tag_ids) WorkflowRun.objects.create( workflow=workflow, diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 924533698..e4cc85087 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -3512,6 +3512,124 @@ class TestWorkflows( as_json=False, ) + @mock.patch("documents.signals.handlers.execute_webhook_action") + def test_workflow_webhook_action_does_not_overwrite_concurrent_tags( + self, + mock_execute_webhook_action, + ): + """ + GIVEN: + - A document updated workflow with only a webhook action + - A tag update that happens after run_workflows + WHEN: + - The workflow runs + THEN: + - The concurrent tag update is preserved + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + webhook_action = WorkflowActionWebhook.objects.create( + use_params=False, + body="Test message: {{doc_url}}", + url="http://paperless-ngx.com", + include_document=False, + ) + action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.WEBHOOK, + webhook=webhook_action, + ) + w = Workflow.objects.create( + name="Webhook workflow", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + inbox_tag = Tag.objects.create(name="inbox") + error_tag = Tag.objects.create(name="error") + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + ) + doc.tags.add(inbox_tag) + + def add_error_tag(*args, **kwargs): + Document.objects.get(pk=doc.pk).tags.add(error_tag) + + mock_execute_webhook_action.side_effect = add_error_tag + + run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc) + + doc.refresh_from_db() + self.assertCountEqual(doc.tags.all(), [inbox_tag, error_tag]) + + @mock.patch("documents.signals.handlers.execute_webhook_action") + def test_workflow_tag_actions_do_not_overwrite_concurrent_tags( + self, + mock_execute_webhook_action, + ): + """ + GIVEN: + - A document updated workflow that clears tags and assigns an inbox tag + - A later tag update that happens before the workflow finishes + WHEN: + - The workflow runs + THEN: + - The later tag update is preserved + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + removal_action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.REMOVAL, + remove_all_tags=True, + ) + assign_action = WorkflowAction.objects.create( + assign_owner=self.user2, + ) + assign_action.assign_tags.add(self.t1) + webhook_action = WorkflowActionWebhook.objects.create( + use_params=False, + body="Test message: {{doc_url}}", + url="http://paperless-ngx.com", + include_document=False, + ) + notify_action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.WEBHOOK, + webhook=webhook_action, + ) + w = Workflow.objects.create( + name="Workflow tag race", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(removal_action) + w.actions.add(assign_action) + w.actions.add(notify_action) + w.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + owner=self.user3, + ) + doc.tags.add(self.t2, self.t3) + + def add_error_tag(*args, **kwargs): + Document.objects.get(pk=doc.pk).tags.add(self.t2) + + mock_execute_webhook_action.side_effect = add_error_tag + + run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc) + + doc.refresh_from_db() + self.assertEqual(doc.owner, self.user2) + self.assertCountEqual(doc.tags.all(), [self.t1, self.t2]) + @override_settings( PAPERLESS_URL="http://localhost:8000", ) diff --git a/src/documents/workflows/mutations.py b/src/documents/workflows/mutations.py index ef85dba0f..2612202e6 100644 --- a/src/documents/workflows/mutations.py +++ b/src/documents/workflows/mutations.py @@ -16,7 +16,6 @@ logger = logging.getLogger("paperless.workflows.mutations") def apply_assignment_to_document( action: WorkflowAction, document: Document, - doc_tag_ids: list[int], logging_group, ): """ @@ -25,12 +24,7 @@ def apply_assignment_to_document( action: WorkflowAction, annotated with 'has_assign_*' boolean fields """ if action.has_assign_tags: - tag_ids_to_add: set[int] = set() - for tag in action.assign_tags.all(): - tag_ids_to_add.add(tag.pk) - tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks()) - - doc_tag_ids[:] = list(set(doc_tag_ids) | tag_ids_to_add) + document.add_nested_tags(action.assign_tags.all()) if action.assign_correspondent: document.correspondent = action.assign_correspondent @@ -197,7 +191,6 @@ def apply_assignment_to_overrides( def apply_removal_to_document( action: WorkflowAction, document: Document, - doc_tag_ids: list[int], ): """ Apply removal actions to a Document instance. @@ -206,14 +199,15 @@ def apply_removal_to_document( """ if action.remove_all_tags: - doc_tag_ids.clear() + document.tags.clear() else: tag_ids_to_remove: set[int] = set() for tag in action.remove_tags.all(): tag_ids_to_remove.add(tag.pk) tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks()) - doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove] + if tag_ids_to_remove: + document.tags.remove(*tag_ids_to_remove) if action.remove_all_correspondents or ( document.correspondent From f3ee820fa4ad453e7791241dce99c67e08eac33c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:59:11 -0700 Subject: [PATCH 6/8] Fix: prevent duplicate parent tag IDs (#12522) --- .../common/input/tags/tags.component.spec.ts | 14 ++++++++++++++ .../components/common/input/tags/tags.component.ts | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts index ae543075e..21ca3eeff 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts @@ -205,6 +205,20 @@ describe('TagsComponent', () => { expect(component.value).toEqual([2, 1]) }) + it('should not duplicate parents when adding sibling nested tags', () => { + const root: Tag = { id: 1, name: 'root' } + const parent: Tag = { id: 2, name: 'parent', parent: 1 } + const leafA: Tag = { id: 3, name: 'leaf-a', parent: 2 } + const leafB: Tag = { id: 4, name: 'leaf-b', parent: 2 } + component.tags = [root, parent, leafA, leafB] + + component.value = [] + component.addTag(3) + component.addTag(4) + + expect(component.value).toEqual([3, 2, 1, 4]) + }) + it('should return ancestors from root to parent using getParentChain', () => { const root: Tag = { id: 1, name: 'root' } const mid: Tag = { id: 2, name: 'mid', parent: 1 } diff --git a/src-ui/src/app/components/common/input/tags/tags.component.ts b/src-ui/src/app/components/common/input/tags/tags.component.ts index 4546dabcb..c540ff9d7 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.ts @@ -153,11 +153,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor { } public onAdd(tag: Tag) { - if (tag.parent) { + if (tag?.parent) { // add all parents recursively const parent = this.getTag(tag.parent) - this.value = [...this.value, parent.id] - this.onAdd(parent) + if (parent && !this.value.includes(parent.id)) { + this.value = [...this.value, parent.id] + this.onAdd(parent) + } } } From e46f4a5aaae41bc3081b5aa5bed7ccc44c674b73 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:43:06 -0700 Subject: [PATCH 7/8] Fix: do not submit permissions for non-owners (#12571) --- .../edit-dialog/edit-dialog.component.spec.ts | 23 +++++++++++++++++++ .../edit-dialog/edit-dialog.component.ts | 20 +++++++++++----- .../user-edit-dialog.component.ts | 2 -- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts index fa845f369..1dbd54edf 100644 --- a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts @@ -26,6 +26,7 @@ import { } from 'src/app/data/matching-model' import { Tag } from 'src/app/data/tag' import { SETTINGS_KEYS } from 'src/app/data/ui-settings' +import { PermissionsService } from 'src/app/services/permissions.service' import { TagService } from 'src/app/services/rest/tag.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' @@ -87,6 +88,7 @@ describe('EditDialogComponent', () => { let component: TestComponent let fixture: ComponentFixture let tagService: TagService + let permissionsService: PermissionsService let settingsService: SettingsService let activeModal: NgbActiveModal let httpTestingController: HttpTestingController @@ -118,8 +120,10 @@ describe('EditDialogComponent', () => { }).compileComponents() tagService = TestBed.inject(TagService) + permissionsService = TestBed.inject(PermissionsService) settingsService = TestBed.inject(SettingsService) settingsService.currentUser = currentUser + permissionsService.initialize([], currentUser as any) activeModal = TestBed.inject(NgbActiveModal) httpTestingController = TestBed.inject(HttpTestingController) @@ -226,6 +230,25 @@ describe('EditDialogComponent', () => { expect(updateSpy).toHaveBeenCalled() }) + it('should not submit owner or permissions for non-owner edits', () => { + component.object = tag + component.dialogMode = EditDialogMode.EDIT + component.ngOnInit() + + component.objectForm.get('name').setValue('Updated tag') + component.save() + + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}tags/${tag.id}/` + ) + expect(req.request.method).toEqual('PUT') + expect(req.request.body.name).toEqual('Updated tag') + expect(req.request.body.owner).toEqual(tag.owner) + expect(req.request.body.set_permissions).toBeUndefined() + + req.flush({}) + }) + it('should create an object on save in edit mode', () => { const createSpy = jest.spyOn(tagService, 'create') component.dialogMode = EditDialogMode.CREATE diff --git a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts index 75534a777..3110f3090 100644 --- a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts @@ -18,6 +18,7 @@ import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { User } from 'src/app/data/user' +import { PermissionsService } from 'src/app/services/permissions.service' import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' @@ -42,6 +43,7 @@ export abstract class EditDialogComponent< protected activeModal = inject(NgbActiveModal) protected userService = inject(UserService) protected settingsService = inject(SettingsService) + protected permissionsService = inject(PermissionsService) users: User[] @@ -69,10 +71,6 @@ export abstract class EditDialogComponent< ngOnInit(): void { if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) { - if ((this.object as ObjectWithPermissions).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, @@ -151,18 +149,28 @@ export abstract class EditDialogComponent< return Object.assign({}, this.objectForm.value) } + protected shouldSubmitPermissions(): boolean { + return ( + this.dialogMode === EditDialogMode.CREATE || + this.permissionsService.currentUserOwnsObject(this.object) + ) + } + save() { this.error = null const formValues = this.getFormValues() const permissionsObject: PermissionsFormObject = this.objectForm.get('permissions_form')?.value - if (permissionsObject) { + if (permissionsObject && this.shouldSubmitPermissions()) { formValues.owner = permissionsObject.owner formValues.set_permissions = permissionsObject.set_permissions - delete formValues.permissions_form } + delete formValues.permissions_form var newObject = Object.assign(Object.assign({}, this.object), formValues) + if (!this.shouldSubmitPermissions()) { + delete newObject['set_permissions'] + } var serverResponse: Observable switch (this.dialogMode) { case EditDialogMode.CREATE: diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts index 1c87a4308..215b2c137 100644 --- a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts @@ -9,7 +9,6 @@ import { first } from 'rxjs' import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' import { Group } from 'src/app/data/group' import { User } from 'src/app/data/user' -import { PermissionsService } from 'src/app/services/permissions.service' import { GroupService } from 'src/app/services/rest/group.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' @@ -39,7 +38,6 @@ export class UserEditDialogComponent implements OnInit { private toastService = inject(ToastService) - private permissionsService = inject(PermissionsService) private groupsService: GroupService groups: Group[] From 1e01ce42c0c1831d2be61a3b1882471b5a47e348 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:10:40 -0700 Subject: [PATCH 8/8] Update usage.md --- docs/usage.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index 1816da736..3bc26a5e5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -392,6 +392,8 @@ still have "object-level" permissions. | View | Confers the ability to view (not edit) a document, tag, etc.
Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. | | Edit | Confers the ability to edit (and view) a document, tag, etc. | +For related metadata such as tags, correspondents, document types, and storage paths, object visibility and document assignment are intentionally distinct. A user may still retain or submit a known object ID when editing a document even if that related object is displayed as _Private_ or omitted from search and selection results. This allows documents to preserve existing assignments that the current user cannot necessarily inspect in detail. + ### Password reset In order to enable the password reset feature you will need to setup an SMTP backend, see