diff --git a/src-ui/src/app/components/admin/config/config.component.html b/src-ui/src/app/components/admin/config/config.component.html index e1d7340a6..c103af166 100644 --- a/src-ui/src/app/components/admin/config/config.component.html +++ b/src-ui/src/app/components/admin/config/config.component.html @@ -19,13 +19,18 @@
-
-
- {{option.title}} - - - +
+
+ {{option.title}}
+ + + + @if (isSet(option.key)) { + + }
@switch (option.type) { diff --git a/src-ui/src/app/components/admin/config/config.component.spec.ts b/src-ui/src/app/components/admin/config/config.component.spec.ts index 079bd1420..f4f4799a6 100644 --- a/src-ui/src/app/components/admin/config/config.component.spec.ts +++ b/src-ui/src/app/components/admin/config/config.component.spec.ts @@ -144,4 +144,18 @@ describe('ConfigComponent', () => { component.uploadFile(new File([], 'test.png'), 'app_logo') expect(initSpy).toHaveBeenCalled() }) + + it('should reset option to null', () => { + component.configForm.patchValue({ output_type: OutputTypeConfig.PDF_A }) + expect(component.isSet('output_type')).toBeTruthy() + component.resetOption('output_type') + expect(component.configForm.get('output_type').value).toBeNull() + expect(component.isSet('output_type')).toBeFalsy() + component.configForm.patchValue({ app_title: 'Test Title' }) + component.resetOption('app_title') + expect(component.configForm.get('app_title').value).toBeNull() + component.configForm.patchValue({ barcodes_enabled: true }) + component.resetOption('barcodes_enabled') + expect(component.configForm.get('barcodes_enabled').value).toBeNull() + }) }) diff --git a/src-ui/src/app/components/admin/config/config.component.ts b/src-ui/src/app/components/admin/config/config.component.ts index eee617310..44e482e75 100644 --- a/src-ui/src/app/components/admin/config/config.component.ts +++ b/src-ui/src/app/components/admin/config/config.component.ts @@ -208,4 +208,12 @@ export class ConfigComponent }, }) } + + public isSet(key: string): boolean { + return this.configForm.get(key).value != null + } + + public resetOption(key: string) { + this.configForm.get(key).setValue(null) + } } diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.html b/src-ui/src/app/components/manage/management-list/management-list.component.html index 8fac6f44f..91dcc2592 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.html +++ b/src-ui/src/app/components/manage/management-list/management-list.component.html @@ -62,9 +62,9 @@ @if (!loading) {
- @if (collectionSize > 0) { + @if (displayCollectionSize > 0) {
- {collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}} + {displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}} @if (selectedObjects.size > 0) {  ({{selectedObjects.size}} selected) } diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts index 86f0f0469..fb0ad0914 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts @@ -229,7 +229,7 @@ describe('ManagementListComponent', () => { expect(reloadSpy).toHaveBeenCalled() }) - it('should use the all list length for collection size when provided', fakeAsync(() => { + it('should use API count for pagination and all ids for displayed total', fakeAsync(() => { jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce( of({ count: 1, @@ -241,7 +241,8 @@ describe('ManagementListComponent', () => { component.reloadData() tick(100) - expect(component.collectionSize).toBe(3) + expect(component.collectionSize).toBe(1) + expect(component.displayCollectionSize).toBe(3) })) it('should support quick filter for objects', () => { diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts index 27913ea7d..8c41f1c45 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts @@ -23,6 +23,7 @@ import { MatchingModel, } from 'src/app/data/matching-model' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' +import { Results } from 'src/app/data/results' import { SortableDirective, SortEvent, @@ -88,6 +89,7 @@ export abstract class ManagementListComponent public page = 1 public collectionSize = 0 + public displayCollectionSize = 0 public sortField: string public sortReverse: boolean @@ -141,6 +143,14 @@ export abstract class ManagementListComponent return data } + protected getCollectionSize(results: Results): number { + return results.all?.length ?? results.count + } + + protected getDisplayCollectionSize(results: Results): number { + return this.getCollectionSize(results) + } + getDocumentCount(object: MatchingModel): number { return ( object.document_count ?? @@ -171,7 +181,8 @@ export abstract class ManagementListComponent tap((c) => { this.unfilteredData = c.results this.data = this.filterData(c.results) - this.collectionSize = c.all?.length ?? c.count + this.collectionSize = this.getCollectionSize(c) + this.displayCollectionSize = this.getDisplayCollectionSize(c) }), delay(100) ) diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts index 0ba0a0855..3749a147f 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts @@ -7,6 +7,7 @@ import { } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' +import { Results } from 'src/app/data/results' import { Tag } from 'src/app/data/tag' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { SortableDirective } from 'src/app/directives/sortable.directive' @@ -77,6 +78,16 @@ export class TagListComponent extends ManagementListComponent { return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent)) } + protected override getCollectionSize(results: Results): number { + // Tag list pages are requested with is_root=true (when unfiltered), so + // pagination must follow root count even though `all` includes descendants + return results.count + } + + protected override getDisplayCollectionSize(results: Results): number { + return super.getCollectionSize(results) + } + protected override getSelectableIDs(tags: Tag[]): number[] { const ids: number[] = [] for (const tag of tags.filter(Boolean)) { diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index bec1254c8..647e0e8b5 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -75,6 +75,7 @@ from documents.parsers import is_mime_type_supported from documents.permissions import get_document_count_filter_for_user from documents.permissions import get_groups_with_only_permission from documents.permissions import get_objects_for_user_owner_aware +from documents.permissions import has_perms_owner_aware from documents.permissions import set_permissions_for_object from documents.regex import validate_regex_pattern from documents.templating.filepath import validate_filepath_template_and_render @@ -2179,6 +2180,17 @@ class ShareLinkSerializer(OwnedObjectSerializer): validated_data["slug"] = get_random_string(50) return super().create(validated_data) + def validate_document(self, document): + if self.user is not None and has_perms_owner_aware( + self.user, + "view_document", + document, + ): + return document + raise PermissionDenied( + _("Insufficient permissions."), + ) + class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin): objects = serializers.ListField( diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index 945f06b67..be7a81cd4 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -773,6 +773,22 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): ], ) + def test_api_selection_data_requires_view_permission(self): + self.doc2.owner = self.user + self.doc2.save() + + user1 = User.objects.create(username="user1") + self.client.force_authenticate(user=user1) + + response = self.client.post( + "/api/documents/selection_data/", + json.dumps({"documents": [self.doc2.id]}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.content, b"Insufficient permissions") + @mock.patch("documents.serialisers.bulk_edit.set_permissions") def test_set_permissions(self, m): self.setup_mock(m, "set_permissions") diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 700f56568..baa0ffc56 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -2905,6 +2905,54 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): ) self.assertEqual(resp.status_code, status.HTTP_200_OK) + def test_create_share_link_requires_view_permission_for_document(self): + """ + GIVEN: + - A user with add_sharelink but without view permission on a document + WHEN: + - API request is made to create a share link for that document + THEN: + - Share link creation is denied until view permission is granted + """ + user1 = User.objects.create_user(username="test1") + user1.user_permissions.add(*Permission.objects.filter(codename="add_sharelink")) + user1.save() + + user2 = User.objects.create_user(username="test2") + user2.save() + + doc = Document.objects.create( + title="test", + mime_type="application/pdf", + content="this is a document which will be protected", + owner=user2, + ) + + self.client.force_authenticate(user1) + + create_resp = self.client.post( + "/api/share_links/", + data={ + "document": doc.pk, + "file_version": "original", + }, + format="json", + ) + self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN) + + assign_perm("view_document", user1, doc) + + create_resp = self.client.post( + "/api/share_links/", + data={ + "document": doc.pk, + "file_version": "original", + }, + format="json", + ) + self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED) + self.assertEqual(create_resp.data["document"], doc.pk) + def test_next_asn(self): """ GIVEN: diff --git a/src/documents/views.py b/src/documents/views.py index 2ce12c330..52687d135 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1850,6 +1850,13 @@ class SelectionDataView(GenericAPIView): serializer.is_valid(raise_exception=True) ids = serializer.validated_data.get("documents") + permitted_documents = get_objects_for_user_owner_aware( + request.user, + "documents.view_document", + Document, + ) + if permitted_documents.filter(pk__in=ids).count() != len(ids): + return HttpResponseForbidden("Insufficient permissions") correspondents = Correspondent.objects.annotate( document_count=Count(