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