diff --git a/resources/lang/en/permissions.php b/resources/lang/en/permissions.php index 27fb14f9b88..ae4e7a9f0c2 100644 --- a/resources/lang/en/permissions.php +++ b/resources/lang/en/permissions.php @@ -31,6 +31,7 @@ 'publish_{collection}_entries_desc' => 'Ability to change from draft to published and vice versa', 'reorder_{collection}_entries' => 'Reorder entries', 'reorder_{collection}_entries_desc' => 'Enables drag and drop reordering', + 'view_other_authors_{collection}_entries' => "View other authors' entries", 'edit_other_authors_{collection}_entries' => "Edit other authors' entries", 'publish_other_authors_{collection}_entries' => "Manage publish state of other authors' entries", 'delete_other_authors_{collection}_entries' => "Delete other authors' entries", diff --git a/src/Auth/CorePermissions.php b/src/Auth/CorePermissions.php index bfd4d74f2df..2916f64947a 100644 --- a/src/Auth/CorePermissions.php +++ b/src/Auth/CorePermissions.php @@ -97,9 +97,11 @@ protected function registerCollections() $this->permission('delete {collection} entries'), $this->permission('publish {collection} entries'), $this->permission('reorder {collection} entries'), - $this->permission('edit other authors {collection} entries')->children([ - $this->permission('publish other authors {collection} entries'), - $this->permission('delete other authors {collection} entries'), + $this->permission('view other authors {collection} entries')->children([ + $this->permission('edit other authors {collection} entries')->children([ + $this->permission('publish other authors {collection} entries'), + $this->permission('delete other authors {collection} entries'), + ]), ]), ]), ])->replacements('collection', function () { diff --git a/src/Entries/ChangeAuthorFieldVisibility.php b/src/Entries/ChangeAuthorFieldVisibility.php new file mode 100644 index 00000000000..7384876b466 --- /dev/null +++ b/src/Entries/ChangeAuthorFieldVisibility.php @@ -0,0 +1,28 @@ +entry) { + return; + } + + if (! $event->authenticatedUser) { + return; + } + + $authorVisibility = match (true) { + $event->authenticatedUser->cant('view-other-authors-entries', [Entry::class, $event->entry->collection()]) => 'hidden', + $event->authenticatedUser->cant('edit-other-authors-entries', [Entry::class, $event->entry->collection(), $event->blueprint]) => 'read_only', + default => 'visible', + }; + + $event->blueprint->ensureFieldHasConfig('author', ['visibility' => $authorVisibility]); + } +} diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 7631364c301..b00bc01764f 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -16,6 +16,7 @@ use Statamic\Facades\Search; use Statamic\Facades\Site; use Statamic\Facades\User; +use Statamic\Http\Controllers\CP\Collections\QueriesAuthorEntries; use Statamic\Http\Resources\CP\Entries\EntriesFieldtypeEntries; use Statamic\Http\Resources\CP\Entries\EntriesFieldtypeEntry as EntryResource; use Statamic\Query\OrderedQueryBuilder; @@ -31,7 +32,8 @@ class Entries extends Relationship { - use QueriesFilters; + use QueriesAuthorEntries, + QueriesFilters; protected $categories = ['relationship']; protected $keywords = ['entry']; @@ -144,6 +146,11 @@ public function getIndexItems($request) $query->whereIn('blueprint', $blueprints); } + collect($this->getConfiguredCollections()) + ->map(fn ($handle) => Collection::findByHandle($handle)) + ->filter(fn ($collection) => User::current()->cant('view-other-authors-entries', [EntryContract::class, $collection])) + ->each(fn ($collection) => $this->queryAuthorEntries($query, $collection)); + $this->activeFilterBadges = $this->queryFilters($query, $filters, $this->getSelectionFilterContext()); if ($sort = $this->getSortColumn($request)) { diff --git a/src/Http/Controllers/CP/Collections/CollectionTreeController.php b/src/Http/Controllers/CP/Collections/CollectionTreeController.php index fb8acc556cc..dbceedf5b7a 100644 --- a/src/Http/Controllers/CP/Collections/CollectionTreeController.php +++ b/src/Http/Controllers/CP/Collections/CollectionTreeController.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Statamic\Contracts\Entries\Collection; +use Statamic\Contracts\Entries\Entry as EntryContract; use Statamic\Facades\Entry; use Statamic\Facades\Site; use Statamic\Facades\User; @@ -24,6 +25,18 @@ public function index(Request $request, Collection $collection) 'site' => $site, ]); + if (User::current()->cant('view-other-authors-entries', [EntryContract::class, $collection])) { + $entriesFromOtherAuthors = collect($pages) + ->map(fn ($page) => Entry::find($page['entry'])) + ->filter(fn ($entry) => $entry->blueprint()->hasField('author')) + ->filter(fn ($entry) => ! $entry->authors()->contains(User::current()->id())) + ->map->id(); + + $pages = collect($pages) + ->filter(fn ($page) => ! $entriesFromOtherAuthors->contains($page['entry'])) + ->values()->all(); + } + return ['pages' => $pages]; } diff --git a/src/Http/Controllers/CP/Collections/CollectionsController.php b/src/Http/Controllers/CP/Collections/CollectionsController.php index 6765779a4db..04075ef2250 100644 --- a/src/Http/Controllers/CP/Collections/CollectionsController.php +++ b/src/Http/Controllers/CP/Collections/CollectionsController.php @@ -24,6 +24,8 @@ class CollectionsController extends CpController { + use QueriesAuthorEntries; + public function index(Request $request) { $this->authorize('index', CollectionContract::class, __('You are not authorized to view collections.')); @@ -55,10 +57,18 @@ private function collections() || User::current()->can('view', $collection) && $collection->sites()->contains(Site::selected()->handle()); })->map(function ($collection) { + $entriesCount = $collection->queryEntries() + ->where('site', Site::selected()) + ->when( + User::current()->cant('view-other-authors-entries', [EntryContract::class, $collection]), + fn ($query) => $this->queryAuthorEntries($query, $collection) + ) + ->count(); + return [ 'id' => $collection->handle(), 'title' => $collection->title(), - 'entries' => $collection->queryEntries()->where('site', Site::selected())->count(), + 'entries' => $entriesCount, 'edit_url' => $collection->editUrl(), 'delete_url' => $collection->deleteUrl(), 'entries_url' => cp_route('collections.show', $collection->handle()), diff --git a/src/Http/Controllers/CP/Collections/EntriesController.php b/src/Http/Controllers/CP/Collections/EntriesController.php index d21796205dd..9df8011a1cc 100644 --- a/src/Http/Controllers/CP/Collections/EntriesController.php +++ b/src/Http/Controllers/CP/Collections/EntriesController.php @@ -26,6 +26,7 @@ class EntriesController extends CpController { use ExtractsFromEntryFields, + QueriesAuthorEntries, QueriesFilters; public function index(FilteredRequest $request, $collection) @@ -70,21 +71,25 @@ protected function indexQuery($collection) $query = $collection->queryEntries(); if ($search = request('search')) { - if ($collection->hasSearchIndex()) { - return $collection - ->searchIndex() - ->ensureExists() - ->search($search) - ->where('collection', $collection->handle()); - } - $query->where('title', 'like', '%'.$search.'%'); } + if ($search && $collection->hasSearchIndex()) { + $query = $collection + ->searchIndex() + ->ensureExists() + ->search($search) + ->where('collection', $collection->handle()); + } + if (Site::multiEnabled()) { $query->whereIn('site', Site::authorized()->map->handle()->all()); } + if (User::current()->cant('view-other-authors-entries', [EntryContract::class, $collection])) { + $this->queryAuthorEntries($query, $collection); + } + return $query; } @@ -103,10 +108,6 @@ public function edit(Request $request, $collection, $entry) $blueprint->setParent($entry); - if (User::current()->cant('edit-other-authors-entries', [EntryContract::class, $collection, $blueprint])) { - $blueprint->ensureFieldHasConfig('author', ['visibility' => 'read_only']); - } - [$values, $meta, $extraValues] = $this->extractFromFields($entry, $blueprint); if ($hasOrigin = $entry->hasOrigin()) { @@ -302,10 +303,6 @@ public function create(Request $request, $collection, $site) throw new \Exception(__('A valid blueprint is required.')); } - if (User::current()->cant('edit-other-authors-entries', [EntryContract::class, $collection, $blueprint])) { - $blueprint->ensureFieldHasConfig('author', ['visibility' => 'read_only']); - } - $values = Entry::make()->collection($collection)->values()->all(); if ($collection->hasStructure() && $request->parent) { diff --git a/src/Http/Controllers/CP/Collections/QueriesAuthorEntries.php b/src/Http/Controllers/CP/Collections/QueriesAuthorEntries.php new file mode 100644 index 00000000000..749bc2ed0da --- /dev/null +++ b/src/Http/Controllers/CP/Collections/QueriesAuthorEntries.php @@ -0,0 +1,46 @@ +blueprintsWithAuthor($collection); + + if (empty($blueprintsWithAuthor)) { + return; + } + + $query->where(fn ($query) => $query + // Exclude entries from other collections (for entries fieldtypes with multiple collections) + ->whereNotIn('collectionHandle', [$collection->handle()]) + // Include entries with blueprints where the current user is the author + ->orWhere(fn ($query) => $query + ->whereIn('blueprint', $blueprintsWithAuthor) + ->whereHas('author', fn ($query) => $query->where('id', User::current()->id())) + ) + // Include entries with blueprints that don't have an author + ->orWhereIn('blueprint', $this->blueprintsWithoutAuthor($collection)) + ); + } + + protected function blueprintsWithAuthor(Collection $collection): array + { + return $collection->entryBlueprints() + ->filter(fn (Blueprint $blueprint) => $blueprint->hasField('author')) + ->map->handle()->all(); + } + + protected function blueprintsWithoutAuthor(Collection $collection): array + { + return $collection->entryBlueprints() + ->filter(fn (Blueprint $blueprint) => ! $blueprint->hasField('author')) + ->map->handle()->all(); + } +} diff --git a/src/Http/Resources/CP/Entries/Entries.php b/src/Http/Resources/CP/Entries/Entries.php index b9172f8676c..1fc5437f0c9 100644 --- a/src/Http/Resources/CP/Entries/Entries.php +++ b/src/Http/Resources/CP/Entries/Entries.php @@ -3,7 +3,9 @@ namespace Statamic\Http\Resources\CP\Entries; use Illuminate\Http\Resources\Json\ResourceCollection; +use Statamic\Contracts\Entries\Entry; use Statamic\CP\Column; +use Statamic\Facades\User; use Statamic\Http\Resources\CP\Concerns\HasRequestedColumns; class Entries extends ResourceCollection @@ -42,6 +44,10 @@ private function setColumns() $columns->put('status', $status); + if (User::current()->cant('view-other-authors-entries', [Entry::class, $this->blueprint->parent()])) { + $columns->get('author')?->listable(false); + } + if ($key = $this->columnPreferenceKey) { $columns->setPreferred($key); } diff --git a/src/Policies/EntryPolicy.php b/src/Policies/EntryPolicy.php index ba6f7ab9e57..be2853e1994 100644 --- a/src/Policies/EntryPolicy.php +++ b/src/Policies/EntryPolicy.php @@ -30,6 +30,10 @@ public function view($user, $entry) return false; } + if ($this->hasAnotherAuthor($user, $entry)) { + return $user->hasPermission("view other authors {$entry->collectionHandle()} entries"); + } + return $this->edit($user, $entry) || $user->hasPermission("view {$entry->collectionHandle()} entries"); } @@ -49,6 +53,11 @@ public function edit($user, $entry) return $user->hasPermission("edit {$entry->collectionHandle()} entries"); } + public function viewOtherAuthorsEntries($user, $collection) + { + return $user->hasPermission("view other authors {$collection->handle()} entries"); + } + public function editOtherAuthorsEntries($user, $collection, $blueprint = null) { $blueprint = $blueprint ?? $collection->entryBlueprint(); diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index 0c8fa5b6a91..620ff80bb64 100755 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -22,6 +22,7 @@ class EventServiceProvider extends ServiceProvider ], \Statamic\Events\EntryBlueprintFound::class => [ \Statamic\Entries\AddSiteColumnToBlueprint::class, + \Statamic\Entries\ChangeAuthorFieldVisibility::class, ], \Statamic\Events\ResponseCreated::class => [ \Statamic\Listeners\ClearState::class, diff --git a/src/UpdateScripts/AddViewOtherAuthorsEntriesPermissions.php b/src/UpdateScripts/AddViewOtherAuthorsEntriesPermissions.php new file mode 100644 index 00000000000..3262189f08d --- /dev/null +++ b/src/UpdateScripts/AddViewOtherAuthorsEntriesPermissions.php @@ -0,0 +1,43 @@ +isUpdatingTo('5.59'); + } + + public function update() + { + Role::all()->each(fn ($role) => $this->updateRole($role)); + } + + private function updateRole($role) + { + $this->getMatchingPermissions($role->permissions(), '/^edit other authors (\w+) entries$/') + ->filter + ->capture + ->each(function ($match) use ($role) { + $role->addPermission("view other authors {$match->capture} entries"); + }); + + $role->save(); + } + + private function getMatchingPermissions($permissions, $regex) + { + return $permissions + ->map(function ($permission) use ($regex) { + $found = preg_match($regex, $permission, $matches); + + return $found + ? (object) ['permission' => $permission, 'capture' => $matches[1] ?? null] + : null; + }) + ->filter(); + } +} diff --git a/tests/Feature/Entries/ViewEntryListingTest.php b/tests/Feature/Entries/ViewEntryListingTest.php index 0d36b634ddc..1cbcb0d10c6 100644 --- a/tests/Feature/Entries/ViewEntryListingTest.php +++ b/tests/Feature/Entries/ViewEntryListingTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Entries\Collection; use Statamic\Facades\User; +use Statamic\Fields\Blueprint; use Tests\FakesRoles; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -90,4 +91,84 @@ public function it_shows_only_entries_in_index_for_sites_user_can_access() $this->assertEquals($expected, $entries->pluck('slug')->all()); } + + #[Test] + public function it_shows_only_entries_in_index_the_user_can_access() + { + $this->setTestRole('view-own-entries', [ + 'access cp', + 'view test entries', + ]); + + $this->setTestRole('view-other-authors-entries', [ + 'access cp', + 'view test entries', + 'view other authors test entries', + ]); + + $userOne = tap(User::make()->assignRole('view-own-entries'))->save(); + $userTwo = tap(User::make()->assignRole('view-other-authors-entries'))->save(); + + Blueprint::make('with-author') + ->setNamespace('collections/test') + ->ensureField('author', ['type' => 'users']) + ->save(); + + Blueprint::make('without-author') + ->setNamespace('collections/test') + ->save(); + + $collection = tap(Collection::make('test'))->save(); + + EntryFactory::collection($collection) + ->slug('entry-user-one') + ->data(['blueprint' => 'with-author', 'author' => $userOne->id()]) + ->create(); + + EntryFactory::collection($collection) + ->slug('entry-user-two') + ->data(['blueprint' => 'with-author', 'author' => $userTwo->id()]) + ->create(); + + EntryFactory::collection($collection) + ->slug('entry-with-multiple-authors') + ->data(['blueprint' => 'with-author', 'author' => [$userOne->id(), $userTwo->id()]]) + ->create(); + + EntryFactory::collection($collection) + ->slug('entry-without-author') + ->data(['blueprint' => 'without-author']) + ->create(); + + $responseUserOne = $this + ->actingAs($userOne) + ->get(cp_route('collections.entries.index', ['collection' => 'test'])) + ->assertOk(); + + $entries = collect($responseUserOne->getData()->data); + + $expected = [ + 'entry-user-one', + 'entry-with-multiple-authors', + 'entry-without-author', + ]; + + $this->assertEquals($expected, $entries->pluck('slug')->all()); + + $responseUserTwo = $this + ->actingAs($userTwo) + ->get(cp_route('collections.entries.index', ['collection' => 'test'])) + ->assertOk(); + + $entries = collect($responseUserTwo->getData()->data); + + $expected = [ + 'entry-user-one', + 'entry-user-two', + 'entry-with-multiple-authors', + 'entry-without-author', + ]; + + $this->assertEquals($expected, $entries->pluck('slug')->all()); + } } diff --git a/tests/UpdateScripts/AddViewOtherAuthorsEntriesPermissionsTest.php b/tests/UpdateScripts/AddViewOtherAuthorsEntriesPermissionsTest.php new file mode 100644 index 00000000000..cffdb5d965c --- /dev/null +++ b/tests/UpdateScripts/AddViewOtherAuthorsEntriesPermissionsTest.php @@ -0,0 +1,82 @@ +title('Webmaster') + ->handle('webmaster') + ->permissions('super') + ->save(); + + Role::make() + ->title('Author') + ->handle('author') + ->permissions([ + 'access cp', + 'create blog entries', + 'edit blog entries', + 'create news entries', + 'edit news entries', + ]) + ->save(); + + Role::make() + ->title('Blog Admin') + ->handle('blog_admin') + ->permissions([ + 'access cp', + 'create blog entries', + 'edit blog entries', + 'publish blog entries', + 'delete blog entries', + 'reorder blog entries', + 'edit other authors blog entries', + 'publish other authors blog entries', + 'delete other authors blog entries', + ]) + ->save(); + + $this->runUpdateScript(AddViewOtherAuthorsEntriesPermissions::class); + + $expectedAuthor = [ + 'access cp', + 'create blog entries', + 'edit blog entries', + 'create news entries', + 'edit news entries', + ]; + + $expectedBlogAdmin = [ + 'access cp', + 'create blog entries', + 'edit blog entries', + 'publish blog entries', + 'delete blog entries', + 'reorder blog entries', + 'edit other authors blog entries', + 'publish other authors blog entries', + 'delete other authors blog entries', + 'view other authors blog entries', // New permission + ]; + + $this->assertTrue(Role::find('webmaster')->isSuper()); + $this->assertEquals($expectedAuthor, Role::find('author')->permissions()->all()); + $this->assertEquals($expectedBlogAdmin, Role::find('blog_admin')->permissions()->all()); + + Role::all()->each->delete(); // Clean up + } +}