]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'development' into bugfix/fix-being-unable-to-clear-filters
authorDan Brown <redacted>
Sat, 15 Oct 2022 14:12:55 +0000 (15:12 +0100)
committerDan Brown <redacted>
Sat, 15 Oct 2022 14:12:55 +0000 (15:12 +0100)
65 files changed:
app/Auth/Permissions/EntityPermission.php
app/Auth/Permissions/JointPermissionBuilder.php
app/Auth/Permissions/PermissionApplicator.php
app/Auth/Permissions/PermissionFormData.php [new file with mode: 0644]
app/Auth/Permissions/PermissionsRepo.php
app/Auth/Permissions/SimpleEntityData.php
app/Auth/Role.php
app/Console/Commands/CopyShelfPermissions.php
app/Entities/Models/Book.php
app/Entities/Models/Bookshelf.php
app/Entities/Models/Chapter.php
app/Entities/Models/Entity.php
app/Entities/Models/Page.php
app/Entities/Models/PageRevision.php
app/Entities/Repos/BookshelfRepo.php
app/Entities/Tools/Cloner.php
app/Entities/Tools/HierarchyTransformer.php
app/Entities/Tools/PermissionsUpdater.php
app/Http/Controllers/BookController.php
app/Http/Controllers/BookshelfController.php
app/Http/Controllers/ChapterController.php
app/Http/Controllers/FavouriteController.php
app/Http/Controllers/PageController.php
app/Http/Controllers/PermissionsController.php [new file with mode: 0644]
app/Http/Controllers/ReferenceController.php
app/Search/SearchRunner.php
database/migrations/2022_10_07_091406_flatten_entity_permissions_table.php [new file with mode: 0644]
database/migrations/2022_10_08_104202_drop_entity_restricted_field.php [new file with mode: 0644]
resources/icons/groups.svg [new file with mode: 0644]
resources/icons/role.svg [new file with mode: 0644]
resources/js/components/entity-permissions-editor.js [deleted file]
resources/js/components/entity-permissions.js [new file with mode: 0644]
resources/js/components/index.js
resources/js/components/permissions-table.js
resources/lang/en/entities.php
resources/sass/_buttons.scss
resources/sass/_components.scss
resources/sass/_forms.scss
resources/sass/_layout.scss
resources/sass/_spacing.scss
resources/views/books/permissions.blade.php
resources/views/books/show.blade.php
resources/views/chapters/permissions.blade.php
resources/views/chapters/show.blade.php
resources/views/form/custom-checkbox.blade.php
resources/views/form/entity-permissions-row.blade.php [new file with mode: 0644]
resources/views/form/entity-permissions.blade.php
resources/views/form/restriction-checkbox.blade.php [deleted file]
resources/views/pages/permissions.blade.php
resources/views/pages/show.blade.php
resources/views/settings/roles/parts/form.blade.php
resources/views/shelves/permissions.blade.php
resources/views/shelves/show.blade.php
routes/web.php
tests/Api/AttachmentsApiTest.php
tests/Commands/CopyShelfPermissionsCommandTest.php
tests/Entity/BookShelfTest.php
tests/Entity/BookTest.php
tests/Entity/ChapterTest.php
tests/Entity/EntitySearchTest.php
tests/Entity/TagTest.php
tests/Helpers/EntityProvider.php
tests/Permissions/EntityPermissionsTest.php
tests/Permissions/RolesTest.php
tests/Uploads/AttachmentTest.php

index 131771a38b7dbee24453440d6b6fd31e72adf594..32ebc440d1dccc0b9274fd935531498dd23cb857 100644 (file)
@@ -2,20 +2,41 @@
 
 namespace BookStack\Auth\Permissions;
 
+use BookStack\Auth\Role;
 use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
 
+/**
+ * @property int $id
+ * @property int $role_id
+ * @property int $entity_id
+ * @property string $entity_type
+ * @property boolean $view
+ * @property boolean $create
+ * @property boolean $update
+ * @property boolean $delete
+ */
 class EntityPermission extends Model
 {
-    protected $fillable = ['role_id', 'action'];
+    public const PERMISSIONS = ['view', 'create', 'update', 'delete'];
+
+    protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
     public $timestamps = false;
 
     /**
-     * Get all this restriction's attached entity.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+     * Get this restriction's attached entity.
      */
-    public function restrictable()
+    public function restrictable(): MorphTo
     {
         return $this->morphTo('restrictable');
     }
+
+    /**
+     * Get the role assigned to this entity permission.
+     */
+    public function role(): BelongsTo
+    {
+        return $this->belongsTo(Role::class);
+    }
 }
index f377eef5ce55057fae0278bb0a9b6febb79511d5..79903c0275afead14926463eda73cf68a9a2c70e 100644 (file)
@@ -40,7 +40,7 @@ class JointPermissionBuilder
         });
 
         // Chunk through all bookshelves
-        Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
+        Bookshelf::query()->withTrashed()->select(['id', 'owned_by'])
             ->chunk(50, function (EloquentCollection $shelves) use ($roles) {
                 $this->createManyJointPermissions($shelves->all(), $roles);
             });
@@ -92,7 +92,7 @@ class JointPermissionBuilder
         });
 
         // Chunk through all bookshelves
-        Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
+        Bookshelf::query()->select(['id', 'owned_by'])
             ->chunk(50, function ($shelves) use ($roles) {
                 $this->createManyJointPermissions($shelves->all(), $roles);
             });
@@ -138,12 +138,11 @@ class JointPermissionBuilder
     protected function bookFetchQuery(): Builder
     {
         return Book::query()->withTrashed()
-            ->select(['id', 'restricted', 'owned_by'])->with([
+            ->select(['id', 'owned_by'])->with([
                 'chapters' => function ($query) {
-                    $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
                 },
                 'pages' => function ($query) {
-                    $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
+                    $query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
                 },
             ]);
     }
@@ -218,7 +217,6 @@ class JointPermissionBuilder
             $simple = new SimpleEntityData();
             $simple->id = $attrs['id'];
             $simple->type = $entity->getMorphClass();
-            $simple->restricted = boolval($attrs['restricted'] ?? 0);
             $simple->owned_by = $attrs['owned_by'] ?? 0;
             $simple->book_id = $attrs['book_id'] ?? null;
             $simple->chapter_id = $attrs['chapter_id'] ?? null;
@@ -240,21 +238,14 @@ class JointPermissionBuilder
         $this->readyEntityCache($entities);
         $jointPermissions = [];
 
-        // Create a mapping of entity restricted statuses
-        $entityRestrictedMap = [];
-        foreach ($entities as $entity) {
-            $entityRestrictedMap[$entity->type . ':' . $entity->id] = $entity->restricted;
-        }
-
         // Fetch related entity permissions
         $permissions = $this->getEntityPermissionsForEntities($entities);
 
         // Create a mapping of explicit entity permissions
         $permissionMap = [];
         foreach ($permissions as $permission) {
-            $key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id;
-            $isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
-            $permissionMap[$key] = $isRestricted;
+            $key = $permission->entity_type . ':' . $permission->entity_id . ':' . $permission->role_id;
+            $permissionMap[$key] = $permission->view;
         }
 
         // Create a mapping of role permissions
@@ -319,11 +310,10 @@ class JointPermissionBuilder
     {
         $idsByType = $this->entitiesToTypeIdMap($entities);
         $permissionFetch = EntityPermission::query()
-            ->where('action', '=', 'view')
             ->where(function (Builder $query) use ($idsByType) {
                 foreach ($idsByType as $type => $ids) {
                     $query->orWhere(function (Builder $query) use ($type, $ids) {
-                        $query->where('restrictable_type', '=', $type)->whereIn('restrictable_id', $ids);
+                        $query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
                     });
                 }
             });
@@ -345,7 +335,7 @@ class JointPermissionBuilder
             return $this->createJointPermissionDataArray($entity, $roleId, true, true);
         }
 
-        if ($entity->restricted) {
+        if ($this->entityPermissionsActiveForRole($permissionMap, $entity, $roleId)) {
             $hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
 
             return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
@@ -358,13 +348,14 @@ class JointPermissionBuilder
         // For chapters and pages, Check if explicit permissions are set on the Book.
         $book = $this->getBook($entity->book_id);
         $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
-        $hasPermissiveAccessToParents = !$book->restricted;
+        $hasPermissiveAccessToParents = !$this->entityPermissionsActiveForRole($permissionMap, $book, $roleId);
 
         // For pages with a chapter, Check if explicit permissions are set on the Chapter
         if ($entity->type === 'page' && $entity->chapter_id !== 0) {
             $chapter = $this->getChapter($entity->chapter_id);
-            $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
-            if ($chapter->restricted) {
+            $chapterRestricted = $this->entityPermissionsActiveForRole($permissionMap, $chapter, $roleId);
+            $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapterRestricted;
+            if ($chapterRestricted) {
                 $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
             }
         }
@@ -377,14 +368,25 @@ class JointPermissionBuilder
         );
     }
 
+    /**
+     * Check if entity permissions are defined within the given map, for the given entity and role.
+     * Checks for the default `role_id=0` backup option as a fallback.
+     */
+    protected function entityPermissionsActiveForRole(array $permissionMap, SimpleEntityData $entity, int $roleId): bool
+    {
+        $keyPrefix = $entity->type . ':' . $entity->id . ':';
+        return isset($permissionMap[$keyPrefix . $roleId]) || isset($permissionMap[$keyPrefix . '0']);
+    }
+
     /**
      * Check for an active restriction in an entity map.
      */
     protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
     {
-        $key = $entity->type . ':' . $entity->id . ':' . $roleId;
+        $roleKey = $entity->type . ':' . $entity->id . ':' . $roleId;
+        $defaultKey = $entity->type . ':' . $entity->id . ':0';
 
-        return $entityMap[$key] ?? false;
+        return $entityMap[$roleKey] ?? $entityMap[$defaultKey] ?? false;
     }
 
     /**
index d840ccd16b89d3ac1021154fa4996c0e2576dcf4..af372cb74002e264a950cfdaaa67cbde2e1f3e7c 100644 (file)
@@ -59,11 +59,15 @@ class PermissionApplicator
      */
     protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
     {
+        $this->ensureValidEntityAction($action);
+
         $adminRoleId = Role::getSystemRole('admin')->id;
         if (in_array($adminRoleId, $userRoleIds)) {
             return true;
         }
 
+        // The chain order here is very important due to the fact we walk up the chain
+        // in the loop below. Earlier items in the chain have higher priority.
         $chain = [$entity];
         if ($entity instanceof Page && $entity->chapter_id) {
             $chain[] = $entity->chapter;
@@ -74,16 +78,26 @@ class PermissionApplicator
         }
 
         foreach ($chain as $currentEntity) {
-            if (is_null($currentEntity->restricted)) {
-                throw new InvalidArgumentException('Entity restricted field used but has not been loaded');
+            $allowedByRoleId = $currentEntity->permissions()
+                ->whereIn('role_id', [0, ...$userRoleIds])
+                ->pluck($action, 'role_id');
+
+            // Continue up the chain if no applicable entity permission overrides.
+            if ($allowedByRoleId->isEmpty()) {
+                continue;
             }
 
-            if ($currentEntity->restricted) {
-                return $currentEntity->permissions()
-                    ->whereIn('role_id', $userRoleIds)
-                    ->where('action', '=', $action)
-                    ->count() > 0;
+            // If we have user-role-specific permissions set, allow if any of those
+            // role permissions allow access.
+            $hasDefault = $allowedByRoleId->has(0);
+            if (!$hasDefault || $allowedByRoleId->count() > 1) {
+                return $allowedByRoleId->search(function (bool $allowed, int $roleId) {
+                        return $roleId !== 0 && $allowed;
+                }) !== false;
             }
+
+            // Otherwise, return the default "Other roles" fallback value.
+            return $allowedByRoleId->get(0);
         }
 
         return null;
@@ -95,18 +109,16 @@ class PermissionApplicator
      */
     public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
     {
-        if (strpos($action, '-') !== false) {
-            throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
-        }
+        $this->ensureValidEntityAction($action);
 
         $permissionQuery = EntityPermission::query()
-            ->where('action', '=', $action)
+            ->where($action, '=', true)
             ->whereIn('role_id', $this->getCurrentUserRoleIds());
 
         if (!empty($entityClass)) {
             /** @var Entity $entityInstance */
             $entityInstance = app()->make($entityClass);
-            $permissionQuery = $permissionQuery->where('restrictable_type', '=', $entityInstance->getMorphClass());
+            $permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
         }
 
         $hasPermission = $permissionQuery->count() > 0;
@@ -255,4 +267,16 @@ class PermissionApplicator
 
         return $this->currentUser()->roles->pluck('id')->values()->all();
     }
+
+    /**
+     * Ensure the given action is a valid and expected entity action.
+     * Throws an exception if invalid otherwise does nothing.
+     * @throws InvalidArgumentException
+     */
+    protected function ensureValidEntityAction(string $action): void
+    {
+        if (!in_array($action, EntityPermission::PERMISSIONS)) {
+            throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
+        }
+    }
 }
diff --git a/app/Auth/Permissions/PermissionFormData.php b/app/Auth/Permissions/PermissionFormData.php
new file mode 100644 (file)
index 0000000..8044a3c
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+
+namespace BookStack\Auth\Permissions;
+
+use BookStack\Auth\Role;
+use BookStack\Entities\Models\Entity;
+
+class PermissionFormData
+{
+    protected Entity $entity;
+
+    public function __construct(Entity $entity)
+    {
+        $this->entity = $entity;
+    }
+
+    /**
+     * Get the permissions with assigned roles.
+     */
+    public function permissionsWithRoles(): array
+    {
+        return $this->entity->permissions()
+            ->with('role')
+            ->where('role_id', '!=', 0)
+            ->get()
+            ->sortBy('role.display_name')
+            ->all();
+    }
+
+    /**
+     * Get the roles that don't yet have specific permissions for the
+     * entity we're managing permissions for.
+     */
+    public function rolesNotAssigned(): array
+    {
+        $assigned = $this->entity->permissions()->pluck('role_id');
+        return Role::query()
+            ->where('system_name', '!=', 'admin')
+            ->whereNotIn('id', $assigned)
+            ->orderBy('display_name', 'asc')
+            ->get()
+            ->all();
+    }
+
+    /**
+     * Get the entity permission for the "Everyone Else" option.
+     */
+    public function everyoneElseEntityPermission(): EntityPermission
+    {
+        /** @var ?EntityPermission $permission */
+        $permission = $this->entity->permissions()
+            ->where('role_id', '=', 0)
+            ->first();
+        return $permission ?? (new EntityPermission());
+    }
+
+    /**
+     * Get the "Everyone Else" role entry.
+     */
+    public function everyoneElseRole(): Role
+    {
+        return (new Role())->forceFill([
+            'id' => 0,
+            'display_name' => trans('entities.permissions_role_everyone_else'),
+            'description' => trans('entities.permissions_role_everyone_else_desc'),
+        ]);
+    }
+}
index 2c2bedb725d0beea98fb03380fbc269af1123a62..6dcef72568343d550c169a4ad7124a035c0223ed 100644 (file)
@@ -139,6 +139,7 @@ class PermissionsRepo
             }
         }
 
+        $role->entityPermissions()->delete();
         $role->jointPermissions()->delete();
         Activity::add(ActivityType::ROLE_DELETE, $role);
         $role->delete();
index 6ec0c4179ed4529f98e945f256803d4d417f3c91..62f5984f8a21274bd36bc4812b4e23ade5766ef2 100644 (file)
@@ -6,7 +6,6 @@ class SimpleEntityData
 {
     public int $id;
     public string $type;
-    public bool $restricted;
     public int $owned_by;
     public ?int $book_id;
     public ?int $chapter_id;
index 51b2ce301eae721fd1a7beb7e0d871f6c522836e..17a4edcc020b0a84c5e1da880d364747069e2abc 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Auth;
 
+use BookStack\Auth\Permissions\EntityPermission;
 use BookStack\Auth\Permissions\JointPermission;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Interfaces\Loggable;
@@ -54,6 +55,14 @@ class Role extends Model implements Loggable
         return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
     }
 
+    /**
+     * Get the entity permissions assigned to this role.
+     */
+    public function entityPermissions(): HasMany
+    {
+        return $this->hasMany(EntityPermission::class);
+    }
+
     /**
      * Check if this role has a permission.
      */
@@ -109,17 +118,6 @@ class Role extends Model implements Loggable
         return static::query()->where('hidden', '=', false)->orderBy('name')->get();
     }
 
-    /**
-     * Get the roles that can be restricted.
-     */
-    public static function restrictable(): Collection
-    {
-        return static::query()
-            ->where('system_name', '!=', 'admin')
-            ->orderBy('display_name', 'asc')
-            ->get();
-    }
-
     /**
      * {@inheritdoc}
      */
index 32adf06839c82d8a78acb8f8ab6e02c621c3d298..ec4c875ffa97d3935d0101383559d7ff63508c54 100644 (file)
@@ -3,7 +3,7 @@
 namespace BookStack\Console\Commands;
 
 use BookStack\Entities\Models\Bookshelf;
-use BookStack\Entities\Repos\BookshelfRepo;
+use BookStack\Entities\Tools\PermissionsUpdater;
 use Illuminate\Console\Command;
 
 class CopyShelfPermissions extends Command
@@ -25,19 +25,16 @@ class CopyShelfPermissions extends Command
      */
     protected $description = 'Copy shelf permissions to all child books';
 
-    /**
-     * @var BookshelfRepo
-     */
-    protected $bookshelfRepo;
+    protected PermissionsUpdater $permissionsUpdater;
 
     /**
      * Create a new command instance.
      *
      * @return void
      */
-    public function __construct(BookshelfRepo $repo)
+    public function __construct(PermissionsUpdater $permissionsUpdater)
     {
-        $this->bookshelfRepo = $repo;
+        $this->permissionsUpdater = $permissionsUpdater;
         parent::__construct();
     }
 
@@ -69,18 +66,18 @@ class CopyShelfPermissions extends Command
                 return;
             }
 
-            $shelves = Bookshelf::query()->get(['id', 'restricted']);
+            $shelves = Bookshelf::query()->get(['id']);
         }
 
         if ($shelfSlug) {
-            $shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id', 'restricted']);
+            $shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
             if ($shelves->count() === 0) {
                 $this->info('No shelves found with the given slug.');
             }
         }
 
         foreach ($shelves as $shelf) {
-            $this->bookshelfRepo->copyDownPermissions($shelf, false);
+            $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf, false);
             $this->info('Copied permissions for shelf [' . $shelf->id . ']');
         }
 
index bf42f20088919a4376a1ce86be548ceff2750f54..fc4556857c72a5d00ed9ec1bb68734489e973e36 100644 (file)
@@ -28,7 +28,7 @@ class Book extends Entity implements HasCoverImage
     public $searchFactor = 1.2;
 
     protected $fillable = ['name', 'description'];
-    protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
+    protected $hidden = ['pivot', 'image_id', 'deleted_at'];
 
     /**
      * Get the url for this book.
@@ -120,4 +120,13 @@ class Book extends Entity implements HasCoverImage
 
         return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
     }
+
+    /**
+     * Get a visible book by its slug.
+     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+     */
+    public static function getBySlug(string $slug): self
+    {
+        return static::visible()->where('slug', '=', $slug)->firstOrFail();
+    }
 }
index cdc6648f96cddb43109eb0eade96dd91726bc0a7..ad52d9d37d4f0ce7df280e7d0fd0052c1b79491b 100644 (file)
@@ -17,7 +17,7 @@ class Bookshelf extends Entity implements HasCoverImage
 
     protected $fillable = ['name', 'description', 'image_id'];
 
-    protected $hidden = ['restricted', 'image_id', 'deleted_at'];
+    protected $hidden = ['image_id', 'deleted_at'];
 
     /**
      * Get the books in this shelf.
@@ -109,4 +109,13 @@ class Bookshelf extends Entity implements HasCoverImage
         $maxOrder = $this->books()->max('order');
         $this->books()->attach($book->id, ['order' => $maxOrder + 1]);
     }
+
+    /**
+     * Get a visible shelf by its slug.
+     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+     */
+    public static function getBySlug(string $slug): self
+    {
+        return static::visible()->where('slug', '=', $slug)->firstOrFail();
+    }
 }
index af4bbd8e3a66238d722272358086aa45f4aa970d..98889ce3f38a430c15d281d64b20b05491fc9bb9 100644 (file)
@@ -19,7 +19,7 @@ class Chapter extends BookChild
     public $searchFactor = 1.2;
 
     protected $fillable = ['name', 'description', 'priority'];
-    protected $hidden = ['restricted', 'pivot', 'deleted_at'];
+    protected $hidden = ['pivot', 'deleted_at'];
 
     /**
      * Get the pages that this chapter contains.
@@ -58,4 +58,13 @@ class Chapter extends BookChild
         ->orderBy('priority', 'asc')
         ->get();
     }
+
+    /**
+     * Get a visible chapter by its book and page slugs.
+     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+     */
+    public static function getBySlugs(string $bookSlug, string $chapterSlug): self
+    {
+        return static::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
+    }
 }
index 26a52073e016358f4f604c47eb3b2f0699fc88dd..8bfe69365ef47e529de6178a03f9e3fe72d80781 100644 (file)
@@ -42,7 +42,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @property Carbon     $deleted_at
  * @property int        $created_by
  * @property int        $updated_by
- * @property bool       $restricted
  * @property Collection $tags
  *
  * @method static Entity|Builder visible()
@@ -176,16 +175,15 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
      */
     public function permissions(): MorphMany
     {
-        return $this->morphMany(EntityPermission::class, 'restrictable');
+        return $this->morphMany(EntityPermission::class, 'entity');
     }
 
     /**
      * Check if this entity has a specific restriction set against it.
      */
-    public function hasRestriction(int $role_id, string $action): bool
+    public function hasPermissions(): bool
     {
-        return $this->permissions()->where('role_id', '=', $role_id)
-            ->where('action', '=', $action)->count() > 0;
+        return $this->permissions()->count() > 0;
     }
 
     /**
index 93729d7f26177f679bf02eafcdc3b1d6b238716e..7a60b3ada05a2c78f5e4c95f7886b1d31ed42619 100644 (file)
@@ -39,7 +39,7 @@ class Page extends BookChild
 
     public $textField = 'text';
 
-    protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
+    protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
 
     protected $casts = [
         'draft'    => 'boolean',
@@ -145,4 +145,13 @@ class Page extends BookChild
 
         return $refreshed;
     }
+
+    /**
+     * Get a visible page by its book and page slugs.
+     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+     */
+    public static function getBySlugs(string $bookSlug, string $pageSlug): self
+    {
+        return static::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
+    }
 }
index 6517b0080bace9a69fd8892972e1542ac83a34bc..cd22db0c83c19e62441af50bd9488281aac9c503 100644 (file)
@@ -31,7 +31,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
 class PageRevision extends Model implements Loggable
 {
     protected $fillable = ['name', 'text', 'summary'];
-    protected $hidden = ['html', 'markdown', 'restricted', 'text'];
+    protected $hidden = ['html', 'markdown', 'text'];
 
     /**
      * Get the user that created the page revision.
index 1f144b1a8d919683ad9c15790f49ac61968152bc..d7759deb43c41a66178e1ffb16e5f16f10422dbf 100644 (file)
@@ -134,31 +134,6 @@ class BookshelfRepo
         $shelf->books()->sync($syncData);
     }
 
-    /**
-     * Copy down the permissions of the given shelf to all child books.
-     */
-    public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
-    {
-        $shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
-        $shelfBooks = $shelf->books()->get(['id', 'restricted', 'owned_by']);
-        $updatedBookCount = 0;
-
-        /** @var Book $book */
-        foreach ($shelfBooks as $book) {
-            if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
-                continue;
-            }
-            $book->permissions()->delete();
-            $book->restricted = $shelf->restricted;
-            $book->permissions()->createMany($shelfPermissions);
-            $book->save();
-            $book->rebuildPermissions();
-            $updatedBookCount++;
-        }
-
-        return $updatedBookCount;
-    }
-
     /**
      * Remove a bookshelf from the system.
      *
index 86f392e6102d3c8f8d8f693fac91ff8251270aa5..52a8f4cf0f2760f45d546803a0e8215d61e6de0f 100644 (file)
@@ -122,8 +122,7 @@ class Cloner
      */
     public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void
     {
-        $targetEntity->restricted = $sourceEntity->restricted;
-        $permissions = $sourceEntity->permissions()->get(['role_id', 'action'])->toArray();
+        $permissions = $sourceEntity->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
         $targetEntity->permissions()->delete();
         $targetEntity->permissions()->createMany($permissions);
         $targetEntity->rebuildPermissions();
index 50d9e2eaeef3db682deba89112dea6dca167e36e..43cf2390e7a5de683399674bc040323866eafa66 100644 (file)
@@ -65,7 +65,7 @@ class HierarchyTransformer
         foreach ($book->chapters as $index => $chapter) {
             $newBook = $this->transformChapterToBook($chapter);
             $shelfBookSyncData[$newBook->id] = ['order' => $index];
-            if (!$newBook->restricted) {
+            if (!$newBook->hasPermissions()) {
                 $this->cloner->copyEntityPermissions($shelf, $newBook);
             }
         }
index c771ee4b68926f98c271436ab9c8b4392fb1f0ab..eb4eb6b48581ae037fd95f911b46beea836bee83 100644 (file)
@@ -3,7 +3,10 @@
 namespace BookStack\Entities\Tools;
 
 use BookStack\Actions\ActivityType;
+use BookStack\Auth\Permissions\EntityPermission;
 use BookStack\Auth\User;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Entity;
 use BookStack\Facades\Activity;
 use Illuminate\Http\Request;
@@ -16,11 +19,9 @@ class PermissionsUpdater
      */
     public function updateFromPermissionsForm(Entity $entity, Request $request)
     {
-        $restricted = $request->get('restricted') === 'true';
-        $permissions = $request->get('restrictions', null);
+        $permissions = $request->get('permissions', null);
         $ownerId = $request->get('owned_by', null);
 
-        $entity->restricted = $restricted;
         $entity->permissions()->delete();
 
         if (!is_null($permissions)) {
@@ -52,18 +53,43 @@ class PermissionsUpdater
     }
 
     /**
-     * Format permissions provided from a permission form to be
-     * EntityPermission data.
+     * Format permissions provided from a permission form to be EntityPermission data.
      */
-    protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): Collection
+    protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): array
     {
-        return collect($permissions)->flatMap(function ($restrictions, $roleId) {
-            return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
-                return [
-                    'role_id' => $roleId,
-                    'action'  => strtolower($action),
-                ];
-            });
-        });
+        $formatted = [];
+
+        foreach ($permissions as $roleId => $info) {
+            $entityPermissionData = ['role_id' => $roleId];
+            foreach (EntityPermission::PERMISSIONS as $permission) {
+                $entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
+            }
+            $formatted[] = $entityPermissionData;
+        }
+
+        return $formatted;
+    }
+
+    /**
+     * Copy down the permissions of the given shelf to all child books.
+     */
+    public function updateBookPermissionsFromShelf(Bookshelf $shelf, $checkUserPermissions = true): int
+    {
+        $shelfPermissions = $shelf->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
+        $shelfBooks = $shelf->books()->get(['id', 'owned_by']);
+        $updatedBookCount = 0;
+
+        /** @var Book $book */
+        foreach ($shelfBooks as $book) {
+            if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
+                continue;
+            }
+            $book->permissions()->delete();
+            $book->permissions()->createMany($shelfPermissions);
+            $book->rebuildPermissions();
+            $updatedBookCount++;
+        }
+
+        return $updatedBookCount;
     }
 }
index cc2f6f534b9f86264564601e896087a0e18e23e6..b323ae496e42586a1702c9dafea528a0810837e5 100644 (file)
@@ -10,7 +10,6 @@ use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Tools\Cloner;
 use BookStack\Entities\Tools\HierarchyTransformer;
-use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Entities\Tools\ShelfContext;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
@@ -209,36 +208,6 @@ class BookController extends Controller
         return redirect('/books');
     }
 
-    /**
-     * Show the permissions view.
-     */
-    public function showPermissions(string $bookSlug)
-    {
-        $book = $this->bookRepo->getBySlug($bookSlug);
-        $this->checkOwnablePermission('restrictions-manage', $book);
-
-        return view('books.permissions', [
-            'book' => $book,
-        ]);
-    }
-
-    /**
-     * Set the restrictions for this book.
-     *
-     * @throws Throwable
-     */
-    public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug)
-    {
-        $book = $this->bookRepo->getBySlug($bookSlug);
-        $this->checkOwnablePermission('restrictions-manage', $book);
-
-        $permissionsUpdater->updateFromPermissionsForm($book, $request);
-
-        $this->showSuccessNotification(trans('entities.books_permissions_updated'));
-
-        return redirect($book->getUrl());
-    }
-
     /**
      * Show the view to copy a book.
      *
index 2143b876a517dc10dd8df31f09101af8ef97e90e..3c63be6318b92b28e1c5ed3e6584538d0bfad6df 100644 (file)
@@ -6,7 +6,6 @@ use BookStack\Actions\ActivityQueries;
 use BookStack\Actions\View;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Repos\BookshelfRepo;
-use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Entities\Tools\ShelfContext;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
@@ -207,46 +206,4 @@ class BookshelfController extends Controller
 
         return redirect('/shelves');
     }
-
-    /**
-     * Show the permissions view.
-     */
-    public function showPermissions(string $slug)
-    {
-        $shelf = $this->shelfRepo->getBySlug($slug);
-        $this->checkOwnablePermission('restrictions-manage', $shelf);
-
-        return view('shelves.permissions', [
-            'shelf' => $shelf,
-        ]);
-    }
-
-    /**
-     * Set the permissions for this bookshelf.
-     */
-    public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $slug)
-    {
-        $shelf = $this->shelfRepo->getBySlug($slug);
-        $this->checkOwnablePermission('restrictions-manage', $shelf);
-
-        $permissionsUpdater->updateFromPermissionsForm($shelf, $request);
-
-        $this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
-
-        return redirect($shelf->getUrl());
-    }
-
-    /**
-     * Copy the permissions of a bookshelf to the child books.
-     */
-    public function copyPermissions(string $slug)
-    {
-        $shelf = $this->shelfRepo->getBySlug($slug);
-        $this->checkOwnablePermission('restrictions-manage', $shelf);
-
-        $updateCount = $this->shelfRepo->copyDownPermissions($shelf);
-        $this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
-
-        return redirect($shelf->getUrl());
-    }
 }
index 6695c28681c69507c07a1c52eb03294023cf582a..4d2bcb2f1d444057daf8dff40fd4060bf7cf0fe4 100644 (file)
@@ -9,7 +9,6 @@ use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Tools\Cloner;
 use BookStack\Entities\Tools\HierarchyTransformer;
 use BookStack\Entities\Tools\NextPreviousContentLocator;
-use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\PermissionsException;
@@ -243,38 +242,6 @@ class ChapterController extends Controller
         return redirect($chapterCopy->getUrl());
     }
 
-    /**
-     * Show the Restrictions view.
-     *
-     * @throws NotFoundException
-     */
-    public function showPermissions(string $bookSlug, string $chapterSlug)
-    {
-        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('restrictions-manage', $chapter);
-
-        return view('chapters.permissions', [
-            'chapter' => $chapter,
-        ]);
-    }
-
-    /**
-     * Set the restrictions for this chapter.
-     *
-     * @throws NotFoundException
-     */
-    public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $chapterSlug)
-    {
-        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('restrictions-manage', $chapter);
-
-        $permissionsUpdater->updateFromPermissionsForm($chapter, $request);
-
-        $this->showSuccessNotification(trans('entities.chapters_permissions_success'));
-
-        return redirect($chapter->getUrl());
-    }
-
     /**
      * Convert the chapter to a book.
      */
index f77b04843ec049f1a0cdf0ccd32413755cb08c08..e46442a643449348ed0f212fee1fb7a00c091b65 100644 (file)
@@ -87,7 +87,7 @@ class FavouriteController extends Controller
 
         $modelInstance = $model->newQuery()
             ->where('id', '=', $modelInfo['id'])
-            ->first(['id', 'name', 'restricted', 'owned_by']);
+            ->first(['id', 'name', 'owned_by']);
 
         $inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
         if (is_null($modelInstance) || $inaccessibleEntity) {
index 748468b211fd1939e8d211861936dec3afca1078..9e09aed16c1a1ee767171cae27fdc29b4c4d6564 100644 (file)
@@ -11,7 +11,6 @@ use BookStack\Entities\Tools\NextPreviousContentLocator;
 use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Tools\PageEditActivity;
 use BookStack\Entities\Tools\PageEditorData;
-use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\References\ReferenceFetcher;
@@ -452,37 +451,4 @@ class PageController extends Controller
 
         return redirect($pageCopy->getUrl());
     }
-
-    /**
-     * Show the Permissions view.
-     *
-     * @throws NotFoundException
-     */
-    public function showPermissions(string $bookSlug, string $pageSlug)
-    {
-        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('restrictions-manage', $page);
-
-        return view('pages.permissions', [
-            'page' => $page,
-        ]);
-    }
-
-    /**
-     * Set the permissions for this page.
-     *
-     * @throws NotFoundException
-     * @throws Throwable
-     */
-    public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $pageSlug)
-    {
-        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('restrictions-manage', $page);
-
-        $permissionsUpdater->updateFromPermissionsForm($page, $request);
-
-        $this->showSuccessNotification(trans('entities.pages_permissions_success'));
-
-        return redirect($page->getUrl());
-    }
 }
diff --git a/app/Http/Controllers/PermissionsController.php b/app/Http/Controllers/PermissionsController.php
new file mode 100644 (file)
index 0000000..7d90873
--- /dev/null
@@ -0,0 +1,174 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Auth\Permissions\EntityPermission;
+use BookStack\Auth\Permissions\PermissionFormData;
+use BookStack\Auth\Role;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\PermissionsUpdater;
+use Illuminate\Http\Request;
+
+class PermissionsController extends Controller
+{
+    protected PermissionsUpdater $permissionsUpdater;
+
+    public function __construct(PermissionsUpdater $permissionsUpdater)
+    {
+        $this->permissionsUpdater = $permissionsUpdater;
+    }
+
+    /**
+     * Show the Permissions view for a page.
+     */
+    public function showForPage(string $bookSlug, string $pageSlug)
+    {
+        $page = Page::getBySlugs($bookSlug, $pageSlug);
+        $this->checkOwnablePermission('restrictions-manage', $page);
+
+        $this->setPageTitle(trans('entities.pages_permissions'));
+        return view('pages.permissions', [
+            'page' => $page,
+            'data' => new PermissionFormData($page),
+        ]);
+    }
+
+    /**
+     * Set the permissions for a page.
+     */
+    public function updateForPage(Request $request, string $bookSlug, string $pageSlug)
+    {
+        $page = Page::getBySlugs($bookSlug, $pageSlug);
+        $this->checkOwnablePermission('restrictions-manage', $page);
+
+        $this->permissionsUpdater->updateFromPermissionsForm($page, $request);
+
+        $this->showSuccessNotification(trans('entities.pages_permissions_success'));
+
+        return redirect($page->getUrl());
+    }
+
+    /**
+     * Show the Restrictions view for a chapter.
+     */
+    public function showForChapter(string $bookSlug, string $chapterSlug)
+    {
+        $chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
+        $this->checkOwnablePermission('restrictions-manage', $chapter);
+
+        $this->setPageTitle(trans('entities.chapters_permissions'));
+        return view('chapters.permissions', [
+            'chapter' => $chapter,
+            'data' => new PermissionFormData($chapter),
+        ]);
+    }
+
+    /**
+     * Set the restrictions for a chapter.
+     */
+    public function updateForChapter(Request $request, string $bookSlug, string $chapterSlug)
+    {
+        $chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
+        $this->checkOwnablePermission('restrictions-manage', $chapter);
+
+        $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
+
+        $this->showSuccessNotification(trans('entities.chapters_permissions_success'));
+
+        return redirect($chapter->getUrl());
+    }
+
+    /**
+     * Show the permissions view for a book.
+     */
+    public function showForBook(string $slug)
+    {
+        $book = Book::getBySlug($slug);
+        $this->checkOwnablePermission('restrictions-manage', $book);
+
+        $this->setPageTitle(trans('entities.books_permissions'));
+        return view('books.permissions', [
+            'book' => $book,
+            'data' => new PermissionFormData($book),
+        ]);
+    }
+
+    /**
+     * Set the restrictions for a book.
+     */
+    public function updateForBook(Request $request, string $slug)
+    {
+        $book = Book::getBySlug($slug);
+        $this->checkOwnablePermission('restrictions-manage', $book);
+
+        $this->permissionsUpdater->updateFromPermissionsForm($book, $request);
+
+        $this->showSuccessNotification(trans('entities.books_permissions_updated'));
+
+        return redirect($book->getUrl());
+    }
+
+    /**
+     * Show the permissions view for a shelf.
+     */
+    public function showForShelf(string $slug)
+    {
+        $shelf = Bookshelf::getBySlug($slug);
+        $this->checkOwnablePermission('restrictions-manage', $shelf);
+
+        $this->setPageTitle(trans('entities.shelves_permissions'));
+        return view('shelves.permissions', [
+            'shelf' => $shelf,
+            'data' => new PermissionFormData($shelf),
+        ]);
+    }
+
+    /**
+     * Set the permissions for a shelf.
+     */
+    public function updateForShelf(Request $request, string $slug)
+    {
+        $shelf = Bookshelf::getBySlug($slug);
+        $this->checkOwnablePermission('restrictions-manage', $shelf);
+
+        $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
+
+        $this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
+
+        return redirect($shelf->getUrl());
+    }
+
+    /**
+     * Copy the permissions of a bookshelf to the child books.
+     */
+    public function copyShelfPermissionsToBooks(string $slug)
+    {
+        $shelf = Bookshelf::getBySlug($slug);
+        $this->checkOwnablePermission('restrictions-manage', $shelf);
+
+        $updateCount = $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf);
+        $this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
+
+        return redirect($shelf->getUrl());
+    }
+
+    /**
+     * Get an empty entity permissions form row for the given role.
+     */
+    public function formRowForRole(string $entityType, string $roleId)
+    {
+        $this->checkPermissionOr('restrictions-manage-all', fn() => userCan('restrictions-manage-own'));
+
+        $role = Role::query()->findOrFail($roleId);
+
+        return view('form.entity-permissions-row', [
+            'role' => $role,
+            'permission' => new EntityPermission(),
+            'entityType' => $entityType,
+            'inheriting' => false,
+        ]);
+    }
+}
index 1daf1818cd04aae2028bb89d18c5e17aeeb77247..b9b3e0eab9613e243a0dd7d67c718c1ef81fd8ec 100644 (file)
@@ -22,8 +22,7 @@ class ReferenceController extends Controller
      */
     public function page(string $bookSlug, string $pageSlug)
     {
-        /** @var Page $page */
-        $page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
+        $page = Page::getBySlugs($bookSlug, $pageSlug);
         $references = $this->referenceFetcher->getPageReferencesToEntity($page);
 
         return view('pages.references', [
@@ -37,8 +36,7 @@ class ReferenceController extends Controller
      */
     public function chapter(string $bookSlug, string $chapterSlug)
     {
-        /** @var Chapter $chapter */
-        $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
+        $chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
         $references = $this->referenceFetcher->getPageReferencesToEntity($chapter);
 
         return view('chapters.references', [
@@ -52,7 +50,7 @@ class ReferenceController extends Controller
      */
     public function book(string $slug)
     {
-        $book = Book::visible()->where('slug', '=', $slug)->firstOrFail();
+        $book = Book::getBySlug($slug);
         $references = $this->referenceFetcher->getPageReferencesToEntity($book);
 
         return view('books.references', [
@@ -66,7 +64,7 @@ class ReferenceController extends Controller
      */
     public function shelf(string $slug)
     {
-        $shelf = Bookshelf::visible()->where('slug', '=', $slug)->firstOrFail();
+        $shelf = Bookshelf::getBySlug($slug);
         $references = $this->referenceFetcher->getPageReferencesToEntity($shelf);
 
         return view('shelves.references', [
index e36edb06c2ca10f3bb7499e7b3e928323894609b..cc44e6125035608469d2d285329490f3d3bb146d 100644 (file)
@@ -162,7 +162,7 @@ class SearchRunner
         $entityQuery = $entityModelInstance->newQuery()->scopes('visible');
 
         if ($entityModelInstance instanceof Page) {
-            $entityQuery->select(array_merge($entityModelInstance::$listAttributes, ['restricted', 'owned_by']));
+            $entityQuery->select(array_merge($entityModelInstance::$listAttributes, ['owned_by']));
         } else {
             $entityQuery->select(['*']);
         }
@@ -447,7 +447,7 @@ class SearchRunner
 
     protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
     {
-        $query->where('restricted', '=', true);
+        $query->whereHas('permissions');
     }
 
     protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input)
diff --git a/database/migrations/2022_10_07_091406_flatten_entity_permissions_table.php b/database/migrations/2022_10_07_091406_flatten_entity_permissions_table.php
new file mode 100644 (file)
index 0000000..468f332
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Query\Builder;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class FlattenEntityPermissionsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        // Remove entries for non-existing roles (Caused by previous lack of deletion handling)
+        $roleIds = DB::table('roles')->pluck('id');
+        DB::table('entity_permissions')->whereNotIn('role_id', $roleIds)->delete();
+
+        // Create new table structure for entity_permissions
+        Schema::create('new_entity_permissions', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedInteger('entity_id');
+            $table->string('entity_type', 25);
+            $table->unsignedInteger('role_id')->index();
+            $table->boolean('view')->default(0);
+            $table->boolean('create')->default(0);
+            $table->boolean('update')->default(0);
+            $table->boolean('delete')->default(0);
+
+            $table->index(['entity_id', 'entity_type']);
+        });
+
+        // Migrate existing entity_permission data into new table structure
+
+        $subSelect = function (Builder $query, string $action, string $subAlias) {
+            $sub = $query->newQuery()->select('action')->from('entity_permissions', $subAlias)
+                ->whereColumn('a.restrictable_id', '=', $subAlias . '.restrictable_id')
+                ->whereColumn('a.restrictable_type', '=', $subAlias . '.restrictable_type')
+                ->whereColumn('a.role_id', '=', $subAlias . '.role_id')
+                ->where($subAlias . '.action', '=', $action);
+            return $query->selectRaw("EXISTS({$sub->toSql()})", $sub->getBindings());
+        };
+
+        $query = DB::table('entity_permissions', 'a')->select([
+            'restrictable_id as entity_id',
+            'restrictable_type as entity_type',
+            'role_id',
+            'view'   => fn(Builder $query) => $subSelect($query, 'view', 'b'),
+            'create' => fn(Builder $query) => $subSelect($query, 'create', 'c'),
+            'update' => fn(Builder $query) => $subSelect($query, 'update', 'd'),
+            'delete' => fn(Builder $query) => $subSelect($query, 'delete', 'e'),
+        ])->groupBy('restrictable_id', 'restrictable_type', 'role_id');
+
+        DB::table('new_entity_permissions')->insertUsing(['entity_id', 'entity_type', 'role_id', 'view', 'create', 'update', 'delete'], $query);
+
+        // Drop old entity_permissions table and replace with new structure
+        Schema::dropIfExists('entity_permissions');
+        Schema::rename('new_entity_permissions', 'entity_permissions');
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        // Create old table structure for entity_permissions
+        Schema::create('old_entity_permissions', function (Blueprint $table) {
+            $table->increments('id');
+            $table->integer('restrictable_id');
+            $table->string('restrictable_type', 191);
+            $table->integer('role_id')->index();
+            $table->string('action', 191)->index();
+
+            $table->index(['restrictable_id', 'restrictable_type']);
+        });
+
+        // Convert newer data format to old data format, and insert into old database
+
+        $actionQuery = function (Builder $query, string $action) {
+            return $query->select([
+                'entity_id as restrictable_id',
+                'entity_type as restrictable_type',
+                'role_id',
+            ])->selectRaw("? as action", [$action])
+            ->from('entity_permissions')
+            ->where($action, '=', true);
+        };
+
+        $query = $actionQuery(DB::query(), 'view')
+            ->union(fn(Builder $query) => $actionQuery($query, 'create'))
+            ->union(fn(Builder $query) => $actionQuery($query, 'update'))
+            ->union(fn(Builder $query) => $actionQuery($query, 'delete'));
+
+        DB::table('old_entity_permissions')->insertUsing(['restrictable_id', 'restrictable_type', 'role_id', 'action'], $query);
+
+        // Drop new entity_permissions table and replace with old structure
+        Schema::dropIfExists('entity_permissions');
+        Schema::rename('old_entity_permissions', 'entity_permissions');
+    }
+}
diff --git a/database/migrations/2022_10_08_104202_drop_entity_restricted_field.php b/database/migrations/2022_10_08_104202_drop_entity_restricted_field.php
new file mode 100644 (file)
index 0000000..063f924
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Query\Builder;
+use Illuminate\Database\Query\JoinClause;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class DropEntityRestrictedField extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        // Remove entity-permissions on non-restricted entities
+        $deleteInactiveEntityPermissions = function (string $table, string $morphClass) {
+            $permissionIds = DB::table('entity_permissions')->select('entity_permissions.id as id')
+                ->join($table, function (JoinClause $join) use ($table, $morphClass) {
+                    return $join->where($table . '.restricted', '=', 0)
+                        ->on($table . '.id', '=', 'entity_permissions.entity_id');
+                })->where('entity_type', '=', $morphClass)
+                ->pluck('id');
+            DB::table('entity_permissions')->whereIn('id', $permissionIds)->delete();
+        };
+        $deleteInactiveEntityPermissions('pages', 'page');
+        $deleteInactiveEntityPermissions('chapters', 'chapter');
+        $deleteInactiveEntityPermissions('books', 'book');
+        $deleteInactiveEntityPermissions('bookshelves', 'bookshelf');
+
+        // Migrate restricted=1 entries to new entity_permissions (role_id=0) entries
+        $defaultEntityPermissionGenQuery = function (Builder $query, string $table, string $morphClass) {
+            return $query->select(['id as entity_id'])
+                ->selectRaw('? as entity_type', [$morphClass])
+                ->selectRaw('? as `role_id`', [0])
+                ->selectRaw('? as `view`', [0])
+                ->selectRaw('? as `create`', [0])
+                ->selectRaw('? as `update`', [0])
+                ->selectRaw('? as `delete`', [0])
+                ->from($table)
+                ->where('restricted', '=', 1);
+        };
+
+        $query = $defaultEntityPermissionGenQuery(DB::query(), 'pages', 'page')
+            ->union(fn(Builder $query) => $defaultEntityPermissionGenQuery($query, 'books', 'book'))
+            ->union(fn(Builder $query) => $defaultEntityPermissionGenQuery($query, 'chapters', 'chapter'))
+            ->union(fn(Builder $query) => $defaultEntityPermissionGenQuery($query, 'bookshelves', 'bookshelf'));
+
+        DB::table('entity_permissions')->insertUsing(['entity_id', 'entity_type', 'role_id', 'view', 'create', 'update', 'delete'], $query);
+
+        // Drop restricted columns
+        $dropRestrictedColumn = fn(Blueprint $table) => $table->dropColumn('restricted');
+        Schema::table('pages', $dropRestrictedColumn);
+        Schema::table('chapters', $dropRestrictedColumn);
+        Schema::table('books', $dropRestrictedColumn);
+        Schema::table('bookshelves', $dropRestrictedColumn);
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        // Create restricted columns
+        $createRestrictedColumn = fn(Blueprint $table) => $table->boolean('restricted')->index()->default(0);
+        Schema::table('pages', $createRestrictedColumn);
+        Schema::table('chapters', $createRestrictedColumn);
+        Schema::table('books', $createRestrictedColumn);
+        Schema::table('bookshelves', $createRestrictedColumn);
+
+        // Set restrictions for entities that have a default entity permission assigned
+        // Note: Possible loss of data where default entity permissions have been configured
+        $restrictEntities = function (string $table, string $morphClass) {
+            $toRestrictIds = DB::table('entity_permissions')
+                ->where('role_id', '=', 0)
+                ->where('entity_type', '=', $morphClass)
+                ->pluck('entity_id');
+            DB::table($table)->whereIn('id', $toRestrictIds)->update(['restricted' => true]);
+        };
+        $restrictEntities('pages', 'page');
+        $restrictEntities('chapters', 'chapter');
+        $restrictEntities('books', 'book');
+        $restrictEntities('bookshelves', 'bookshelf');
+
+        // Delete default entity permissions
+        DB::table('entity_permissions')->where('role_id', '=', 0)->delete();
+    }
+}
diff --git a/resources/icons/groups.svg b/resources/icons/groups.svg
new file mode 100644 (file)
index 0000000..c99a6b5
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px"><g><path d="M12,12.75c1.63,0,3.07,0.39,4.24,0.9c1.08,0.48,1.76,1.56,1.76,2.73L18,17c0,0.55-0.45,1-1,1H7c-0.55,0-1-0.45-1-1l0-0.61 c0-1.18,0.68-2.26,1.76-2.73C8.93,13.14,10.37,12.75,12,12.75z M4,13c1.1,0,2-0.9,2-2c0-1.1-0.9-2-2-2s-2,0.9-2,2 C2,12.1,2.9,13,4,13z M5.13,14.1C4.76,14.04,4.39,14,4,14c-0.99,0-1.93,0.21-2.78,0.58C0.48,14.9,0,15.62,0,16.43L0,17 c0,0.55,0.45,1,1,1l3.5,0v-1.61C4.5,15.56,4.73,14.78,5.13,14.1z M20,13c1.1,0,2-0.9,2-2c0-1.1-0.9-2-2-2s-2,0.9-2,2 C18,12.1,18.9,13,20,13z M24,16.43c0-0.81-0.48-1.53-1.22-1.85C21.93,14.21,20.99,14,20,14c-0.39,0-0.76,0.04-1.13,0.1 c0.4,0.68,0.63,1.46,0.63,2.29V18l3.5,0c0.55,0,1-0.45,1-1L24,16.43z M12,6c1.66,0,3,1.34,3,3c0,1.66-1.34,3-3,3s-3-1.34-3-3 C9,7.34,10.34,6,12,6z"/></g></svg>
\ No newline at end of file
diff --git a/resources/icons/role.svg b/resources/icons/role.svg
new file mode 100644 (file)
index 0000000..e7cad50
--- /dev/null
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/>
+</svg>
\ No newline at end of file
diff --git a/resources/js/components/entity-permissions-editor.js b/resources/js/components/entity-permissions-editor.js
deleted file mode 100644 (file)
index a821792..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-
-class EntityPermissionsEditor {
-
-  constructor(elem) {
-    this.permissionsTable = elem.querySelector('[permissions-table]');
-
-    // Handle toggle all event
-    this.restrictedCheckbox = elem.querySelector('[name=restricted]');
-    this.restrictedCheckbox.addEventListener('change', this.updateTableVisibility.bind(this));
-  }
-
-  updateTableVisibility() {
-    this.permissionsTable.style.display =
-      this.restrictedCheckbox.checked
-        ? null
-        : 'none';
-  }
-}
-
-export default EntityPermissionsEditor;
\ No newline at end of file
diff --git a/resources/js/components/entity-permissions.js b/resources/js/components/entity-permissions.js
new file mode 100644 (file)
index 0000000..917dcc7
--- /dev/null
@@ -0,0 +1,80 @@
+/**
+ * @extends {Component}
+ */
+class EntityPermissions {
+
+    setup() {
+        this.container = this.$el;
+        this.entityType = this.$opts.entityType;
+
+        this.everyoneInheritToggle = this.$refs.everyoneInherit;
+        this.roleSelect = this.$refs.roleSelect;
+        this.roleContainer = this.$refs.roleContainer;
+
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        // "Everyone Else" inherit toggle
+        this.everyoneInheritToggle.addEventListener('change', event => {
+            const inherit = event.target.checked;
+            const permissions = document.querySelectorAll('input[name^="permissions[0]["]');
+            for (const permission of permissions) {
+                permission.disabled = inherit;
+                permission.checked = false;
+            }
+        });
+
+        // Remove role row button click
+        this.container.addEventListener('click', event => {
+            const button = event.target.closest('button');
+            if (button && button.dataset.roleId) {
+                this.removeRowOnButtonClick(button)
+            }
+        });
+
+        // Role select change
+        this.roleSelect.addEventListener('change', event => {
+            const roleId = this.roleSelect.value;
+            if (roleId) {
+                this.addRoleRow(roleId);
+            }
+        });
+    }
+
+    async addRoleRow(roleId) {
+        this.roleSelect.disabled = true;
+
+        // Remove option from select
+        const option = this.roleSelect.querySelector(`option[value="${roleId}"]`);
+        if (option) {
+            option.remove();
+        }
+
+        // Get and insert new row
+        const resp = await window.$http.get(`/permissions/form-row/${this.entityType}/${roleId}`);
+        const wrap = document.createElement('div');
+        wrap.innerHTML = resp.data;
+        const row = wrap.children[0];
+        this.roleContainer.append(row);
+        window.components.init(row);
+
+        this.roleSelect.disabled = false;
+    }
+
+    removeRowOnButtonClick(button) {
+        const row = button.closest('.content-permissions-row');
+        const roleId = button.dataset.roleId;
+        const roleName = button.dataset.roleName;
+
+        const option = document.createElement('option');
+        option.value = roleId;
+        option.textContent = roleName;
+
+        this.roleSelect.append(option);
+        row.remove();
+    }
+
+}
+
+export default EntityPermissions;
\ No newline at end of file
index f360e2b0c2b57e2178e4716df96513a36ed3d220..7d00cb671130841ae9477f62d0af9f4a9df8d047 100644 (file)
@@ -18,7 +18,7 @@ import dropdown from "./dropdown.js"
 import dropdownSearch from "./dropdown-search.js"
 import dropzone from "./dropzone.js"
 import editorToolbox from "./editor-toolbox.js"
-import entityPermissionsEditor from "./entity-permissions-editor.js"
+import entityPermissions from "./entity-permissions";
 import entitySearch from "./entity-search.js"
 import entitySelector from "./entity-selector.js"
 import entitySelectorPopup from "./entity-selector-popup.js"
@@ -75,7 +75,7 @@ const componentMapping = {
     "dropdown-search": dropdownSearch,
     "dropzone": dropzone,
     "editor-toolbox": editorToolbox,
-    "entity-permissions-editor": entityPermissionsEditor,
+    "entity-permissions": entityPermissions,
     "entity-search": entitySearch,
     "entity-selector": entitySelector,
     "entity-selector-popup": entitySelectorPopup,
index baad7525833c0040b96b51cf0e9fe5e8adf69f66..df3c055cafa037f59a551b7887a3fb0f355646f4 100644 (file)
@@ -1,22 +1,21 @@
 
 class PermissionsTable {
 
-    constructor(elem) {
-        this.container = elem;
+    setup() {
+        this.container = this.$el;
 
         // Handle toggle all event
-        const toggleAll = elem.querySelector('[permissions-table-toggle-all]');
-        toggleAll.addEventListener('click', this.toggleAllClick.bind(this));
+        for (const toggleAllElem of (this.$manyRefs.toggleAll || [])) {
+            toggleAllElem.addEventListener('click', this.toggleAllClick.bind(this));
+        }
 
         // Handle toggle row event
-        const toggleRowElems = elem.querySelectorAll('[permissions-table-toggle-all-in-row]');
-        for (let toggleRowElem of toggleRowElems) {
+        for (const toggleRowElem of (this.$manyRefs.toggleRow || [])) {
             toggleRowElem.addEventListener('click', this.toggleRowClick.bind(this));
         }
 
         // Handle toggle column event
-        const toggleColumnElems = elem.querySelectorAll('[permissions-table-toggle-all-in-column]');
-        for (let toggleColElem of toggleColumnElems) {
+        for (const toggleColElem of (this.$manyRefs.toggleColumn || [])) {
             toggleColElem.addEventListener('click', this.toggleColumnClick.bind(this));
         }
     }
index 1720801d2757440eb7a428d836bd7e453ae9323b..bf6201900fc3653724e85d715411695325b6fd0a 100644 (file)
@@ -42,10 +42,14 @@ return [
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
-    'permissions_intro' => 'Once enabled, These permissions will take priority over any set role permissions.',
-    'permissions_enable' => 'Enable Custom Permissions',
+    'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.',
+    'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.',
+    'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.',
     'permissions_save' => 'Save Permissions',
     'permissions_owner' => 'Owner',
+    'permissions_role_everyone_else' => 'Everyone Else',
+    'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.',
+    'permissions_role_override' => 'Override permissions for role',
 
     // Search
     'search_results' => 'Search Results',
index 714dfc42c5905212c752a010489435774fecc79b..fb3af06e869e4698b8ca1dea50be39f02460f33c 100644 (file)
@@ -48,9 +48,10 @@ button {
 
 .button.outline {
   background-color: transparent;
-  @include lightDark(color, #666, #aaa);
+  @include lightDark(color, #666, #AAA);
   fill: currentColor;
-  border: 1px solid #CCC;
+  border: 1px solid;
+  @include lightDark(border-color, #CCC, #666);
   &:hover, &:focus, &:active {
     border: 1px solid #CCC;
     box-shadow: none;
@@ -109,12 +110,23 @@ button {
   display: block;
 }
 
-.button.icon {
+.button.icon, .icon-button {
   .svg-icon {
     margin-inline-end: 0;
   }
 }
 
+.icon-button {
+  text-align: center;
+  border: 1px solid transparent;
+}
+.icon-button:hover {
+  background-color: rgba(0, 0, 0, 0.05);
+  border-radius: 4px;
+  @include lightDark(border-color, #DDD, #444);
+  cursor: pointer;
+}
+
 .button.svg {
   display: flex;
   align-items: center;
index c00f57954b0b58ae73eebe2c7f9203a895778e20..9fdd5a6117eb2b650965603bc2fac4159bc717db 100644 (file)
@@ -798,11 +798,35 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   max-width: 500px;
 }
 
-.permissions-table [permissions-table-toggle-all-in-row] {
-  display: none;
+.content-permissions {
+  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
+}
+.content-permissions-row {
+  border: 1.5px solid;
+  @include lightDark(border-color, #E2E2E2, #444);
+  border-bottom-width: 0;
+  label {
+    padding-bottom: 0;
+  }
+  &:hover {
+    @include lightDark(background-color, #F2F2F2, #333);
+  }
+}
+.content-permissions-row:first-child {
+  border-radius: 4px 4px 0 0;
 }
-.permissions-table tr:hover [permissions-table-toggle-all-in-row] {
-  display: inline;
+.content-permissions-row:last-child {
+  border-radius: 0 0 4px 4px;
+  border-bottom-width: 1.5px;
+}
+.content-permissions-row:first-child:last-child {
+  border-radius: 4px;
+}
+.content-permissions-row-toggle-all {
+  visibility: hidden;
+}
+.content-permissions-row:hover .content-permissions-row-toggle-all {
+  visibility: visible;
 }
 
 .template-item {
@@ -857,7 +881,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   gap: $-s;
   line-height: normal;
   .svg-icon {
-    height: 16px;
+    height: 26px;
+    width: 26px;
     margin: 0;
   }
   .avatar {
@@ -879,10 +904,11 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   white-space: nowrap;
 }
 .dropdown-search-toggle-select-caret {
-  font-size: 1.5rem;
   line-height: 0;
   margin-left: auto;
   margin-top: -2px;
+  display: flex;
+  align-items: center;
 }
 
 .dropdown-search-dropdown {
index 7025aa8984eda05f24188ce80fdcc7629e638fca..7e0f72355f3ee1f07275e07260483ecf8f168dea 100644 (file)
@@ -207,8 +207,8 @@ select {
   -moz-appearance: none;
   appearance: none;
   background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='%23666666'><polygon points='0,0 100,0 50,50'/></svg>");
-  background-size: 12px;
-  background-position: calc(100% - 20px) 70%;
+  background-size: 10px 12px;
+  background-position: calc(100% - 20px) 64%;
   background-repeat: no-repeat;
 
   @include rtl {
@@ -266,6 +266,15 @@ input[type=color] {
     background-color: rgba(0, 0, 0, 0.05);
     opacity: 0.8;
   }
+  input[type=checkbox][disabled] ~ * {
+    opacity: 0.8;
+    cursor: not-allowed;
+  }
+  input[type=checkbox][disabled] ~ .custom-checkbox {
+    border-color: #999;
+    color: #999 !important;
+    background: #f2f2f2;
+  }
 }
 .toggle-switch-list {
   .toggle-switch {
index 2cd57d496559c142c404863bd942db0dca8125e6..cfb8397c99207e4ba3a436ed35c556c39caab22f 100644 (file)
@@ -158,8 +158,8 @@ body.flexbox {
   }
 }
 
-.gap-m {
-  gap: $-m;
+.flex-none {
+  flex: none;
 }
 
 .justify-flex-start {
index 40217de9b344c37541c063f295c51f928c5ade0d..14f8918dcb4650fadc3047e02727889d61847d7a 100644 (file)
   }
 }
 @include spacing('margin', 'm');
-@include spacing('padding', 'p');
\ No newline at end of file
+@include spacing('padding', 'p');
+
+@each $sizeLetter, $size in $spacing {
+  .gap-#{$sizeLetter} {
+    gap: $size !important;
+  }
+  .gap-x-#{$sizeLetter} {
+    column-gap: $size !important;
+  }
+  .gap-y-#{$sizeLetter} {
+    row-gap: $size !important;
+  }
+}
index d72042d42f5efe3d1ec4d4abfb97f0aa7f74e3d9..2e43338cd30202a3a8ba0152960a12f01e7584d4 100644 (file)
@@ -14,9 +14,8 @@
             ]])
         </div>
 
-        <main class="card content-wrap">
-            <h1 class="list-heading">{{ trans('entities.books_permissions') }}</h1>
-            @include('form.entity-permissions', ['model' => $book])
+        <main class="card content-wrap auto-height">
+            @include('form.entity-permissions', ['model' => $book, 'title' => trans('entities.books_permissions')])
         </main>
     </div>
 
index 76a4a600523355580017126cd6a982ac649a50be..b95b69d1b73748fc61985d3ecefd7dedfc9882b3 100644 (file)
@@ -71,7 +71,7 @@
         <h5>{{ trans('common.details') }}</h5>
         <div class="blended-links">
             @include('entities.meta', ['entity' => $book])
-            @if($book->restricted)
+            @if($book->hasPermissions())
                 <div class="active-restriction">
                     @if(userCan('restrictions-manage', $book))
                         <a href="{{ $book->getUrl('/permissions') }}" class="entity-meta-item">
index 6b4e219384a746e22841b27c60fbff24a9c48e37..acdaf0ab919fb5f4e7f0f3a17b74f5c4a95bce82 100644 (file)
@@ -15,9 +15,8 @@
             ]])
         </div>
 
-        <main class="card content-wrap">
-            <h1 class="list-heading">{{ trans('entities.chapters_permissions') }}</h1>
-            @include('form.entity-permissions', ['model' => $chapter])
+        <main class="card content-wrap auto-height">
+            @include('form.entity-permissions', ['model' => $chapter, 'title' => trans('entities.chapters_permissions')])
         </main>
     </div>
 
index 1ae2d68471211b33e494882fc74769dd69f58de0..b3496eae23717f8bb648430ba149d06a84ab7945 100644 (file)
@@ -69,7 +69,7 @@
         <div class="blended-links">
             @include('entities.meta', ['entity' => $chapter])
 
-            @if($book->restricted)
+            @if($book->hasPermissions())
                 <div class="active-restriction">
                     @if(userCan('restrictions-manage', $book))
                         <a href="{{ $book->getUrl('/permissions') }}" class="entity-meta-item">
@@ -85,7 +85,7 @@
                 </div>
             @endif
 
-            @if($chapter->restricted)
+            @if($chapter->hasPermissions())
                 <div class="active-restriction">
                     @if(userCan('restrictions-manage', $chapter))
                         <a href="{{ $chapter->getUrl('/permissions') }}" class="entity-meta-item">
index 2bf4e223201a5e04b8accd48b575f7b887f378c5..de3ffe922c13145c04a2e08c87d5ca80d33857b0 100644 (file)
@@ -5,7 +5,7 @@ $checked
 $label
 --}}
 <label custom-checkbox class="toggle-switch @if($errors->has($name)) text-neg @endif">
-    <input type="checkbox" name="{{$name}}" value="{{ $value }}" @if($checked) checked="checked" @endif>
+    <input type="checkbox" name="{{$name}}" value="{{ $value }}" @if($checked) checked="checked" @endif @if($disabled ?? false) disabled="disabled" @endif>
     <span tabindex="0" role="checkbox"
           aria-checked="{{ $checked ? 'true' : 'false' }}"
           class="custom-checkbox text-primary">@icon('check')</span>
diff --git a/resources/views/form/entity-permissions-row.blade.php b/resources/views/form/entity-permissions-row.blade.php
new file mode 100644 (file)
index 0000000..d2e6a47
--- /dev/null
@@ -0,0 +1,88 @@
+{{--
+$role - The Role to display this row for.
+$entityType - String identifier for type of entity having permissions applied.
+$permission - The entity permission containing the permissions.
+$inheriting - Boolean if the current row should be marked as inheriting default permissions. Used for "Everyone Else" role.
+--}}
+
+<div component="permissions-table" class="content-permissions-row flex-container-row justify-space-between wrap">
+    <div class="gap-x-m flex-container-row items-center px-l py-m flex">
+        <div class="text-large" title="{{ $role->id === 0 ? trans('entities.permissions_role_everyone_else') : trans('common.role') }}">
+            @icon($role->id === 0 ? 'groups' : 'role')
+        </div>
+        <span>
+            <strong>{{ $role->display_name }}</strong> <br>
+            <small class="text-muted">{{ $role->description }}</small>
+        </span>
+        @if($role->id !== 0)
+            <button type="button"
+                class="ml-auto flex-none text-small text-primary text-button hover-underline content-permissions-row-toggle-all hide-under-s"
+                refs="permissions-table@toggle-all"
+                ><strong>{{ trans('common.toggle_all') }}</strong></button>
+        @endif
+    </div>
+    @if($role->id === 0)
+        <div class="px-l flex-container-row items-center" refs="entity-permissions@everyone-inherit">
+            @include('form.custom-checkbox', [
+                'name' => 'entity-permissions-inherit',
+                'label' => 'Inherit defaults',
+                'value' => 'true',
+                'checked' => $inheriting
+            ])
+        </div>
+    @endif
+    <div class="flex-container-row justify-space-between gap-x-xl wrap items-center">
+        <input type="hidden" name="permissions[{{ $role->id }}][active]"
+               @if($inheriting) disabled="disabled" @endif
+               value="true">
+        <div class="px-l">
+            @include('form.custom-checkbox', [
+                'name' =>  'permissions[' . $role->id . '][view]',
+                'label' => trans('common.view'),
+                'value' => 'true',
+                'checked' => $permission->view,
+                'disabled' => $inheriting
+            ])
+        </div>
+        @if($entityType !== 'page')
+            <div class="px-l">
+                @include('form.custom-checkbox', [
+                    'name' =>  'permissions[' . $role->id . '][create]',
+                    'label' => trans('common.create'),
+                    'value' => 'true',
+                    'checked' => $permission->create,
+                    'disabled' => $inheriting
+                ])
+            </div>
+        @endif
+        <div class="px-l">
+            @include('form.custom-checkbox', [
+                'name' =>  'permissions[' . $role->id . '][update]',
+                'label' => trans('common.update'),
+                'value' => 'true',
+                'checked' => $permission->update,
+                'disabled' => $inheriting
+            ])
+        </div>
+        <div class="px-l">
+            @include('form.custom-checkbox', [
+                'name' =>  'permissions[' . $role->id . '][delete]',
+                'label' => trans('common.delete'),
+                'value' => 'true',
+                'checked' => $permission->delete,
+                'disabled' => $inheriting
+            ])
+        </div>
+    </div>
+    @if($role->id !== 0)
+        <div class="flex-container-row items-center px-m py-s">
+            <button type="button"
+                    class="text-neg p-m icon-button"
+                    data-role-id="{{ $role->id }}"
+                    data-role-name="{{ $role->display_name }}"
+                    title="{{ trans('common.remove') }}">
+                @icon('close') <span class="hide-over-m ml-xs">{{ trans('common.remove') }}</span>
+            </button>
+        </div>
+    @endif
+</div>
\ No newline at end of file
index 206955fe94864021f23bac4b07082a6f0a09e362..724d0fb393658b4f5152331d8ba3117d4386ca2b 100644 (file)
@@ -1,54 +1,73 @@
-<form action="{{ $model->getUrl('/permissions') }}" method="POST" entity-permissions-editor>
+<?php
+  /** @var \BookStack\Auth\Permissions\PermissionFormData $data */
+?>
+<form component="entity-permissions"
+      option:entity-permissions:entity-type="{{ $model->getType() }}"
+      action="{{ $model->getUrl('/permissions') }}"
+      method="POST">
     {!! csrf_field() !!}
     <input type="hidden" name="_method" value="PUT">
 
-    <div class="grid half left-focus v-center">
+    <div class="grid half left-focus v-end gap-m wrap">
         <div>
-            <p class="mb-none mt-m">{{ trans('entities.permissions_intro') }}</p>
-            <div>
-                @include('form.checkbox', [
-                    'name' => 'restricted',
-                    'label' => trans('entities.permissions_enable'),
-                ])
-            </div>
+            <h1 class="list-heading">{{ $title }}</h1>
+            <p class="text-muted mb-s">
+                {{ trans('entities.permissions_desc') }}
+
+                @if($model instanceof \BookStack\Entities\Models\Book)
+                    <br> {{ trans('entities.permissions_book_cascade') }}
+                @elseif($model instanceof \BookStack\Entities\Models\Chapter)
+                    <br> {{ trans('entities.permissions_chapter_cascade') }}
+                @endif
+            </p>
+
+            @if($model instanceof \BookStack\Entities\Models\Bookshelf)
+                <p class="text-warn">{{ trans('entities.shelves_permissions_cascade_warning') }}</p>
+            @endif
         </div>
-        <div>
-            <div class="form-group">
+        <div class="flex-container-row justify-flex-end">
+            <div class="form-group mb-m">
                 <label for="owner">{{ trans('entities.permissions_owner') }}</label>
                 @include('form.user-select', ['user' => $model->ownedBy, 'name' => 'owned_by'])
             </div>
         </div>
     </div>
 
-    @if($model instanceof \BookStack\Entities\Models\Bookshelf)
-        <p class="text-warn">{{ trans('entities.shelves_permissions_cascade_warning') }}</p>
-    @endif
-
     <hr>
 
-    <table permissions-table class="table permissions-table toggle-switch-list" style="{{ !$model->restricted ? 'display: none' : '' }}">
-        <tr>
-            <th>{{ trans('common.role') }}</th>
-            <th colspan="{{ $model->isA('page') ? '3' : '4'  }}">
-                {{ trans('common.actions') }}
-                <a href="#" permissions-table-toggle-all class="text-small ml-m text-primary">{{ trans('common.toggle_all') }}</a>
-            </th>
-        </tr>
-        @foreach(\BookStack\Auth\Role::restrictable() as $role)
-            <tr>
-                <td width="33%" class="pt-m">
-                    {{ $role->display_name }}
-                    <a href="#" permissions-table-toggle-all-in-row class="text-small float right ml-m text-primary">{{ trans('common.toggle_all') }}</a>
-                </td>
-                <td>@include('form.restriction-checkbox', ['name'=>'restrictions', 'label' => trans('common.view'), 'action' => 'view'])</td>
-                @if(!$model->isA('page'))
-                    <td>@include('form.restriction-checkbox', ['name'=>'restrictions', 'label' => trans('common.create'), 'action' => 'create'])</td>
-                @endif
-                <td>@include('form.restriction-checkbox', ['name'=>'restrictions', 'label' => trans('common.update'), 'action' => 'update'])</td>
-                <td>@include('form.restriction-checkbox', ['name'=>'restrictions', 'label' => trans('common.delete'), 'action' => 'delete'])</td>
-            </tr>
+    <div refs="entity-permissions@role-container" class="content-permissions mt-m mb-m">
+        @foreach($data->permissionsWithRoles() as $permission)
+            @include('form.entity-permissions-row', [
+                'permission' => $permission,
+                'role' => $permission->role,
+                'entityType' => $model->getType(),
+                'inheriting' => false,
+            ])
         @endforeach
-    </table>
+    </div>
+
+    <div class="flex-container-row justify-flex-end mb-xl">
+        <div class="flex-container-row items-center gap-m">
+            <label for="role_select" class="m-none p-none"><span class="bold">{{ trans('entities.permissions_role_override') }}</span></label>
+            <select name="role_select" id="role_select" refs="entity-permissions@role-select">
+                <option value="">{{ trans('common.select') }}</option>
+                @foreach($data->rolesNotAssigned() as $role)
+                    <option value="{{ $role->id }}">{{ $role->display_name }}</option>
+                @endforeach
+            </select>
+        </div>
+    </div>
+
+    <div class="content-permissions mt-m mb-xl">
+        @include('form.entity-permissions-row', [
+                'role' => $data->everyoneElseRole(),
+                'permission' => $data->everyoneElseEntityPermission(),
+                'entityType' => $model->getType(),
+                'inheriting' => !$model->permissions()->where('role_id', '=', 0)->exists(),
+            ])
+    </div>
+
+    <hr class="mb-m">
 
     <div class="text-right">
         <a href="{{ $model->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
diff --git a/resources/views/form/restriction-checkbox.blade.php b/resources/views/form/restriction-checkbox.blade.php
deleted file mode 100644 (file)
index 02c477f..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-{{--
-$name
-$label
-$role
-$action
-$model?
---}}
-@include('form.custom-checkbox', [
-    'name' => $name . '[' . $role->id . '][' . $action . ']',
-    'label' => $label,
-    'value' => 'true',
-    'checked' => isset($model) && $model->hasRestriction($role->id, $action)
-])
\ No newline at end of file
index 792015e28bb3a405975e3f12b1888ba662c9eb4a..93e14ee0da0d40f75bc31efd502a4338fb16ba12 100644 (file)
@@ -16,9 +16,8 @@
             ]])
         </div>
 
-        <main class="card content-wrap">
-            <h1 class="list-heading">{{ trans('entities.pages_permissions') }}</h1>
-            @include('form.entity-permissions', ['model' => $page])
+        <main class="card content-wrap auto-height">
+            @include('form.entity-permissions', ['model' => $page, 'title' => trans('entities.pages_permissions')])
         </main>
     </div>
 
index b2c57c3196672b9fb209d4d6244ef7d48d0ef01c..c053a3f943a5ad1042af489c60ff14804c291bee 100644 (file)
@@ -81,7 +81,7 @@
         <div class="blended-links">
             @include('entities.meta', ['entity' => $page])
 
-            @if($book->restricted)
+            @if($book->hasPermissions())
                 <div class="active-restriction">
                     @if(userCan('restrictions-manage', $book))
                         <a href="{{ $book->getUrl('/permissions') }}" class="entity-meta-item">
@@ -97,7 +97,7 @@
                 </div>
             @endif
 
-            @if($page->chapter && $page->chapter->restricted)
+            @if($page->chapter && $page->chapter->hasPermissions())
                 <div class="active-restriction">
                     @if(userCan('restrictions-manage', $page->chapter))
                         <a href="{{ $page->chapter->getUrl('/permissions') }}" class="entity-meta-item">
                 </div>
             @endif
 
-            @if($page->restricted)
+            @if($page->hasPermissions())
                 <div class="active-restriction">
                     @if(userCan('restrictions-manage', $page))
                         <a href="{{ $page->getUrl('/permissions') }}" class="entity-meta-item">
index 5937919973d83a2bcc9e1c28b9922288c3fb1b95..044b4ceb47eba02fe6efd48a65548472f0cfbc87 100644 (file)
@@ -26,9 +26,9 @@
         </div>
     </div>
 
-    <div permissions-table>
+    <div component="permissions-table">
         <label class="setting-list-label">{{ trans('settings.role_system') }}</label>
-        <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+        <a href="#" refs="permissions-table@toggle-all" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
 
         <div class="toggle-switch-list grid half mt-m">
             <div>
             <p class="text-warn">{{ trans('settings.role_asset_admins') }}</p>
         @endif
 
-        <table permissions-table class="table toggle-switch-list compact permissions-table">
+        <table component="permissions-table" class="table toggle-switch-list compact permissions-table">
             <tr>
                 <th width="20%">
-                    <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                    <a href="#" refs="permissions-table@toggle-all" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
                 </th>
-                <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.create') }}</th>
-                <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.view') }}</th>
-                <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.edit') }}</th>
-                <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.delete') }}</th>
+                <th width="20%" refs="permissions-table@toggle-column">{{ trans('common.create') }}</th>
+                <th width="20%" refs="permissions-table@toggle-column">{{ trans('common.view') }}</th>
+                <th width="20%" refs="permissions-table@toggle-column">{{ trans('common.edit') }}</th>
+                <th width="20%" refs="permissions-table@toggle-column">{{ trans('common.delete') }}</th>
             </tr>
             <tr>
                 <td>
                     <div>{{ trans('entities.shelves') }}</div>
-                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
                 </td>
                 <td>
                     @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-create-all', 'label' => trans('settings.role_all')])
@@ -93,7 +93,7 @@
             <tr>
                 <td>
                     <div>{{ trans('entities.books') }}</div>
-                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
                 </td>
                 <td>
                     @include('settings.roles.parts.checkbox', ['permission' => 'book-create-all', 'label' => trans('settings.role_all')])
             <tr>
                 <td>
                     <div>{{ trans('entities.chapters') }}</div>
-                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
                 </td>
                 <td>
                     @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-own', 'label' => trans('settings.role_own')])
             <tr>
                 <td>
                     <div>{{ trans('entities.pages') }}</div>
-                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
                 </td>
                 <td>
                     @include('settings.roles.parts.checkbox', ['permission' => 'page-create-own', 'label' => trans('settings.role_own')])
             <tr>
                 <td>
                     <div>{{ trans('entities.images') }}</div>
-                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
                 </td>
                 <td>@include('settings.roles.parts.checkbox', ['permission' => 'image-create-all', 'label' => ''])</td>
                 <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}<sup>1</sup></small></td>
             <tr>
                 <td>
                     <div>{{ trans('entities.attachments') }}</div>
-                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
                 </td>
                 <td>@include('settings.roles.parts.checkbox', ['permission' => 'attachment-create-all', 'label' => ''])</td>
                 <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
             <tr>
                 <td>
                     <div>{{ trans('entities.comments') }}</div>
-                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
                 </td>
                 <td>@include('settings.roles.parts.checkbox', ['permission' => 'comment-create-all', 'label' => ''])</td>
                 <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
index a26325518d6ac5294c38c1a89e5c7a1fdeb117c0..e79b34096ea73c46959daa026652c9fa6dd81a2f 100644 (file)
@@ -2,7 +2,7 @@
 
 @section('body')
 
-    <div class="container small">
+    <div class="container">
 
         <div class="my-s">
             @include('entities.breadcrumbs', ['crumbs' => [
         </div>
 
         <div class="card content-wrap auto-height">
-            <h1 class="list-heading">{{ trans('entities.shelves_permissions') }}</h1>
-            @include('form.entity-permissions', ['model' => $shelf])
+            @include('form.entity-permissions', ['model' => $shelf, 'title' => trans('entities.shelves_permissions')])
         </div>
 
-        <div class="card content-wrap auto-height">
-            <h2 class="list-heading">{{ trans('entities.shelves_copy_permissions_to_books') }}</h2>
-            <p>{{ trans('entities.shelves_copy_permissions_explain') }}</p>
-            <form action="{{ $shelf->getUrl('/copy-permissions') }}" method="post" class="text-right">
+        <div class="card content-wrap auto-height flex-container-row items-center gap-x-xl wrap">
+            <div class="flex">
+                <h2 class="list-heading">{{ trans('entities.shelves_copy_permissions_to_books') }}</h2>
+                <p>{{ trans('entities.shelves_copy_permissions_explain') }}</p>
+            </div>
+            <form action="{{ $shelf->getUrl('/copy-permissions') }}" method="post" class="flex text-right">
                 {{ csrf_field() }}
                 <button class="button">{{ trans('entities.shelves_copy_permissions') }}</button>
             </form>
index 306d55e54ea5d318c045cdcab2faa69b8301eff5..37d2889563063be922a8683d60ee655787bcf7df 100644 (file)
@@ -85,7 +85,7 @@
         <h5>{{ trans('common.details') }}</h5>
         <div class="blended-links">
             @include('entities.meta', ['entity' => $shelf])
-            @if($shelf->restricted)
+            @if($shelf->hasPermissions())
                 <div class="active-restriction">
                     @if(userCan('restrictions-manage', $shelf))
                         <a href="{{ $shelf->getUrl('/permissions') }}" class="entity-meta-item">
index 26d4b6f133b39618f6836d42e6235770633f9141..1cffbfd7d8d20c7d5779d30a69980e0fc760fe2f 100644 (file)
@@ -19,6 +19,7 @@ use BookStack\Http\Controllers\PageController;
 use BookStack\Http\Controllers\PageExportController;
 use BookStack\Http\Controllers\PageRevisionController;
 use BookStack\Http\Controllers\PageTemplateController;
+use BookStack\Http\Controllers\PermissionsController;
 use BookStack\Http\Controllers\RecycleBinController;
 use BookStack\Http\Controllers\ReferenceController;
 use BookStack\Http\Controllers\RoleController;
@@ -61,9 +62,9 @@ Route::middleware('auth')->group(function () {
     Route::get('/shelves/{slug}', [BookshelfController::class, 'show']);
     Route::put('/shelves/{slug}', [BookshelfController::class, 'update']);
     Route::delete('/shelves/{slug}', [BookshelfController::class, 'destroy']);
-    Route::get('/shelves/{slug}/permissions', [BookshelfController::class, 'showPermissions']);
-    Route::put('/shelves/{slug}/permissions', [BookshelfController::class, 'permissions']);
-    Route::post('/shelves/{slug}/copy-permissions', [BookshelfController::class, 'copyPermissions']);
+    Route::get('/shelves/{slug}/permissions', [PermissionsController::class, 'showForShelf']);
+    Route::put('/shelves/{slug}/permissions', [PermissionsController::class, 'updateForShelf']);
+    Route::post('/shelves/{slug}/copy-permissions', [PermissionsController::class, 'copyShelfPermissionsToBooks']);
     Route::get('/shelves/{slug}/references', [ReferenceController::class, 'shelf']);
 
     // Book Creation
@@ -79,8 +80,8 @@ Route::middleware('auth')->group(function () {
     Route::delete('/books/{id}', [BookController::class, 'destroy']);
     Route::get('/books/{slug}/sort-item', [BookSortController::class, 'showItem']);
     Route::get('/books/{slug}', [BookController::class, 'show']);
-    Route::get('/books/{bookSlug}/permissions', [BookController::class, 'showPermissions']);
-    Route::put('/books/{bookSlug}/permissions', [BookController::class, 'permissions']);
+    Route::get('/books/{bookSlug}/permissions', [PermissionsController::class, 'showForBook']);
+    Route::put('/books/{bookSlug}/permissions', [PermissionsController::class, 'updateForBook']);
     Route::get('/books/{slug}/delete', [BookController::class, 'showDelete']);
     Route::get('/books/{bookSlug}/copy', [BookController::class, 'showCopy']);
     Route::post('/books/{bookSlug}/copy', [BookController::class, 'copy']);
@@ -111,8 +112,8 @@ Route::middleware('auth')->group(function () {
     Route::post('/books/{bookSlug}/page/{pageSlug}/copy', [PageController::class, 'copy']);
     Route::get('/books/{bookSlug}/page/{pageSlug}/delete', [PageController::class, 'showDelete']);
     Route::get('/books/{bookSlug}/draft/{pageId}/delete', [PageController::class, 'showDeleteDraft']);
-    Route::get('/books/{bookSlug}/page/{pageSlug}/permissions', [PageController::class, 'showPermissions']);
-    Route::put('/books/{bookSlug}/page/{pageSlug}/permissions', [PageController::class, 'permissions']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/permissions', [PermissionsController::class, 'showForPage']);
+    Route::put('/books/{bookSlug}/page/{pageSlug}/permissions', [PermissionsController::class, 'updateForPage']);
     Route::get('/books/{bookSlug}/page/{pageSlug}/references', [ReferenceController::class, 'page']);
     Route::put('/books/{bookSlug}/page/{pageSlug}', [PageController::class, 'update']);
     Route::delete('/books/{bookSlug}/page/{pageSlug}', [PageController::class, 'destroy']);
@@ -138,12 +139,12 @@ Route::middleware('auth')->group(function () {
     Route::post('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'copy']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [ChapterController::class, 'edit']);
     Route::post('/books/{bookSlug}/chapter/{chapterSlug}/convert-to-book', [ChapterController::class, 'convertToBook']);
-    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'showPermissions']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'showForChapter']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ChapterExportController::class, 'pdf']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ChapterExportController::class, 'html']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ChapterExportController::class, 'markdown']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ChapterExportController::class, 'plainText']);
-    Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'permissions']);
+    Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [ChapterController::class, 'showDelete']);
     Route::delete('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'destroy']);
@@ -214,6 +215,9 @@ Route::middleware('auth')->group(function () {
     Route::get('/', [HomeController::class, 'index']);
     Route::get('/home', [HomeController::class, 'index']);
 
+    // Permissions
+    Route::get('/permissions/form-row/{entityType}/{roleId}', [PermissionsController::class, 'formRowForRole']);
+
     // Maintenance
     Route::get('/settings/maintenance', [MaintenanceController::class, 'index']);
     Route::delete('/settings/maintenance/cleanup-images', [MaintenanceController::class, 'cleanupImages']);
index c295f738439c2cd1a5cc53d8d0a6b378207e077c..4d1d3b340b153c3c588ebdcb7103453d5f98d4be 100644 (file)
@@ -50,9 +50,7 @@ class AttachmentsApiTest extends TestCase
             ],
         ]]);
 
-        $page->restricted = true;
-        $page->save();
-        $this->entities->regenPermissions($page);
+        $this->entities->setPermissions($page, [], []);
 
         $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
         $resp->assertJsonMissing(['data' => [
index 55b710ba9afeb28d8854426d37ed7a940ed0a04d..cb9a845fda300487b28ecb694c3976cb5d88041e 100644 (file)
@@ -19,7 +19,7 @@ class CopyShelfPermissionsCommandTest extends TestCase
         $shelf = $this->entities->shelf();
         $child = $shelf->books()->first();
         $editorRole = $this->getEditor()->roles()->first();
-        $this->assertFalse(boolval($child->restricted), 'Child book should not be restricted by default');
+        $this->assertFalse($child->hasPermissions(), 'Child book should not be restricted by default');
         $this->assertTrue($child->permissions()->count() === 0, 'Child book should have no permissions by default');
 
         $this->entities->setPermissions($shelf, ['view', 'update'], [$editorRole]);
@@ -28,10 +28,14 @@ class CopyShelfPermissionsCommandTest extends TestCase
         ]);
         $child = $shelf->books()->first();
 
-        $this->assertTrue(boolval($child->restricted), 'Child book should now be restricted');
-        $this->assertTrue($child->permissions()->count() === 2, 'Child book should have copied permissions');
-        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
-        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
+        $this->assertTrue($child->hasPermissions(), 'Child book should now be restricted');
+        $this->assertEquals(2, $child->permissions()->count(), 'Child book should have copied permissions');
+        $this->assertDatabaseHas('entity_permissions', [
+            'entity_type' => 'book',
+            'entity_id' => $child->id,
+            'role_id' => $editorRole->id,
+            'view' => true, 'update' => true, 'create' => false, 'delete' => false,
+        ]);
     }
 
     public function test_copy_shelf_permissions_command_using_all()
@@ -40,7 +44,7 @@ class CopyShelfPermissionsCommandTest extends TestCase
         Bookshelf::query()->where('id', '!=', $shelf->id)->delete();
         $child = $shelf->books()->first();
         $editorRole = $this->getEditor()->roles()->first();
-        $this->assertFalse(boolval($child->restricted), 'Child book should not be restricted by default');
+        $this->assertFalse($child->hasPermissions(), 'Child book should not be restricted by default');
         $this->assertTrue($child->permissions()->count() === 0, 'Child book should have no permissions by default');
 
         $this->entities->setPermissions($shelf, ['view', 'update'], [$editorRole]);
@@ -48,9 +52,13 @@ class CopyShelfPermissionsCommandTest extends TestCase
             ->expectsQuestion('Permission settings for all shelves will be cascaded. Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. Are you sure you want to proceed?', 'y');
         $child = $shelf->books()->first();
 
-        $this->assertTrue(boolval($child->restricted), 'Child book should now be restricted');
-        $this->assertTrue($child->permissions()->count() === 2, 'Child book should have copied permissions');
-        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
-        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
+        $this->assertTrue($child->hasPermissions(), 'Child book should now be restricted');
+        $this->assertEquals(2, $child->permissions()->count(), 'Child book should have copied permissions');
+        $this->assertDatabaseHas('entity_permissions', [
+            'entity_type' => 'book',
+            'entity_id' => $child->id,
+            'role_id' => $editorRole->id,
+            'view' => true, 'update' => true, 'create' => false, 'delete' => false,
+        ]);
     }
 }
index 1e740b94eb89820086492aefcfa0b2c00a5dd427..5d919f12bc2ed92775e4ed147b75c4c194877c91 100644 (file)
@@ -295,7 +295,7 @@ class BookShelfTest extends TestCase
 
         $child = $shelf->books()->first();
         $editorRole = $this->getEditor()->roles()->first();
-        $this->assertFalse(boolval($child->restricted), 'Child book should not be restricted by default');
+        $this->assertFalse($child->hasPermissions(), 'Child book should not be restricted by default');
         $this->assertTrue($child->permissions()->count() === 0, 'Child book should have no permissions by default');
 
         $this->entities->setPermissions($shelf, ['view', 'update'], [$editorRole]);
@@ -303,10 +303,14 @@ class BookShelfTest extends TestCase
         $child = $shelf->books()->first();
 
         $resp->assertRedirect($shelf->getUrl());
-        $this->assertTrue(boolval($child->restricted), 'Child book should now be restricted');
+        $this->assertTrue($child->hasPermissions(), 'Child book should now be restricted');
         $this->assertTrue($child->permissions()->count() === 2, 'Child book should have copied permissions');
-        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
-        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
+        $this->assertDatabaseHas('entity_permissions', [
+            'entity_type' => 'book',
+            'entity_id' => $child->id,
+            'role_id' => $editorRole->id,
+            'view' => true, 'update' => true, 'create' => false, 'delete' => false,
+        ]);
     }
 
     public function test_permission_page_has_a_warning_about_no_cascading()
index 2914162cf6dfb7f3b9a10f5a4f7ea330e38467c2..cccff3a1f58da6edb792267e85ca4c0297ba0856 100644 (file)
@@ -304,9 +304,7 @@ class BookTest extends TestCase
         // Hide child content
         /** @var BookChild $page */
         foreach ($book->getDirectChildren() as $child) {
-            $child->restricted = true;
-            $child->save();
-            $this->entities->regenPermissions($child);
+            $this->entities->setPermissions($child, [], []);
         }
 
         $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
index afc60c20eda782819bb23c5f05fbfad94f5441b4..b726280c9184afe8ded72ca442d8b53c58d67bb9 100644 (file)
@@ -101,9 +101,7 @@ class ChapterTest extends TestCase
         // Hide pages to all non-admin roles
         /** @var Page $page */
         foreach ($chapter->pages as $page) {
-            $page->restricted = true;
-            $page->save();
-            $this->entities->regenPermissions($page);
+            $this->entities->setPermissions($page, [], []);
         }
 
         $this->asEditor()->post($chapter->getUrl('/copy'), [
index 3cfd928203f5e6801ae069e8e12ec858f6bd002c..c309f2167954b3f99ce344c13fef926cb2280946 100644 (file)
@@ -132,9 +132,8 @@ class EntitySearchTest extends TestCase
     public function test_search_filters()
     {
         $page = $this->entities->newPage(['name' => 'My new test quaffleachits', 'html' => 'this is about an orange donkey danzorbhsing']);
-        $this->asEditor();
-        $editorId = $this->getEditor()->id;
-        $editorSlug = $this->getEditor()->slug;
+        $editor = $this->getEditor();
+        $this->actingAs($editor);
 
         // Viewed filter searches
         $this->get('/search?term=' . urlencode('danzorbhsing {not_viewed_by_me}'))->assertSee($page->name);
@@ -147,22 +146,22 @@ class EntitySearchTest extends TestCase
         $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertDontSee($page->name);
         $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name);
         $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertDontSee($page->name);
-        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:' . $editorSlug . '}'))->assertDontSee($page->name);
-        $page->created_by = $editorId;
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:' . $editor->slug . '}'))->assertDontSee($page->name);
+        $page->created_by = $editor->id;
         $page->save();
         $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertSee($page->name);
-        $this->get('/search?term=' . urlencode('danzorbhsing {created_by: ' . $editorSlug . '}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {created_by: ' . $editor->slug . '}'))->assertSee($page->name);
         $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name);
         $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertDontSee($page->name);
-        $page->updated_by = $editorId;
+        $page->updated_by = $editor->id;
         $page->save();
         $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertSee($page->name);
-        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:' . $editorSlug . '}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:' . $editor->slug . '}'))->assertSee($page->name);
         $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertDontSee($page->name);
-        $page->owned_by = $editorId;
+        $page->owned_by = $editor->id;
         $page->save();
         $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertSee($page->name);
-        $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:' . $editorSlug . '}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:' . $editor->slug . '}'))->assertSee($page->name);
 
         // Content filters
         $this->get('/search?term=' . urlencode('{in_name:danzorbhsing}'))->assertDontSee($page->name);
@@ -172,8 +171,7 @@ class EntitySearchTest extends TestCase
 
         // Restricted filter
         $this->get('/search?term=' . urlencode('danzorbhsing {is_restricted}'))->assertDontSee($page->name);
-        $page->restricted = true;
-        $page->save();
+        $this->entities->setPermissions($page, ['view'], [$editor->roles->first()]);
         $this->get('/search?term=' . urlencode('danzorbhsing {is_restricted}'))->assertSee($page->name);
 
         // Date filters
index ab06276013f041a4c99a686f6f379a4e0e42969a..ed5c798a5f614be01dbffc714f23ec2badf71dac 100644 (file)
@@ -75,9 +75,7 @@ class TagTest extends TestCase
         $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson(['color', 'country']);
 
         // Set restricted permission the page
-        $page->restricted = true;
-        $page->save();
-        $page->rebuildPermissions();
+        $this->entities->setPermissions($page, [], []);
 
         $this->asAdmin()->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson(['color', 'country']);
         $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson([]);
@@ -180,8 +178,7 @@ class TagTest extends TestCase
         $resp = $this->get('/tags?name=SuperCategory');
         $resp->assertSee('GreatTestContent');
 
-        $page->restricted = true;
-        $this->entities->regenPermissions($page);
+        $this->entities->setPermissions($page, [], []);
 
         $resp = $this->asEditor()->get('/tags');
         $resp->assertDontSee('SuperCategory');
index 05925909e568671606fd16fbfdac5e529642486e..9e8cf0b73ba28c979ab80806134324ce891a404b 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Tests\Helpers;
 
+use BookStack\Auth\Permissions\EntityPermission;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
@@ -203,21 +204,22 @@ class EntityProvider
      */
     public function setPermissions(Entity $entity, array $actions = [], array $roles = []): void
     {
-        $entity->restricted = true;
         $entity->permissions()->delete();
 
-        $permissions = [];
-        foreach ($actions as $action) {
-            foreach ($roles as $role) {
-                $permissions[] = [
-                    'role_id' => $role->id,
-                    'action'  => strtolower($action),
-                ];
+        $permissions = [
+            // Set default permissions to not allow actions so that only the provided role permissions are at play.
+            ['role_id' => 0, 'view' => false, 'create' => false, 'update' => false, 'delete' => false],
+        ];
+
+        foreach ($roles as $role) {
+            $permission = ['role_id' => $role->id];
+            foreach (EntityPermission::PERMISSIONS as $possibleAction) {
+                $permission[$possibleAction] = in_array($possibleAction, $actions);
             }
+            $permissions[] = $permission;
         }
 
         $entity->permissions()->createMany($permissions);
-        $entity->save();
         $entity->load('permissions');
         $this->regenPermissions($entity);
     }
index 7f91e7887b96887a610cfcff59c354b16f1c8f4c..6b99ba365defa99e19bf49a50501dc31c371882a 100644 (file)
@@ -376,20 +376,18 @@ class EntityPermissionsTest extends TestCase
             ->assertSee($title);
 
         $this->put($modelInstance->getUrl('/permissions'), [
-            'restricted'   => 'true',
-            'restrictions' => [
+            'permissions' => [
                 $roleId => [
                     $permission => 'true',
                 ],
             ],
         ]);
 
-        $this->assertDatabaseHas($modelInstance->getTable(), ['id' => $modelInstance->id, 'restricted' => true]);
         $this->assertDatabaseHas('entity_permissions', [
-            'restrictable_id'   => $modelInstance->id,
-            'restrictable_type' => $modelInstance->getMorphClass(),
+            'entity_id'   => $modelInstance->id,
+            'entity_type' => $modelInstance->getMorphClass(),
             'role_id'           => $roleId,
-            'action'            => $permission,
+            $permission         => true,
         ]);
     }
 
index 7512c6d2fb0b220c0d4cb9c2f3c3fe43832f0a2b..88d400259e0e683a8c4d3a21906b0842aa6e6e7e 100644 (file)
@@ -163,6 +163,29 @@ class RolesTest extends TestCase
         $this->assertEquals($this->user->id, $roleA->users()->first()->id);
     }
 
+    public function test_entity_permissions_are_removed_on_delete()
+    {
+        /** @var Role $roleA */
+        $roleA = Role::query()->create(['display_name' => 'Entity Permissions Delete Test']);
+        $page = $this->entities->page();
+
+        $this->entities->setPermissions($page, ['view'], [$roleA]);
+
+        $this->assertDatabaseHas('entity_permissions', [
+            'role_id' => $roleA->id,
+            'entity_id' => $page->id,
+            'entity_type' => $page->getMorphClass(),
+        ]);
+
+        $this->asAdmin()->delete("/settings/roles/delete/$roleA->id");
+
+        $this->assertDatabaseMissing('entity_permissions', [
+            'role_id' => $roleA->id,
+            'entity_id' => $page->id,
+            'entity_type' => $page->getMorphClass(),
+        ]);
+    }
+
     public function test_image_view_notice_shown_on_role_form()
     {
         /** @var Role $role */
index 915a9ba4d26a5fec7bcd4454919cecd19e2ecd8e..b6fcb8f69995d3767d5dc1a8f3f140b5f469d66a 100644 (file)
@@ -253,11 +253,7 @@ class AttachmentTest extends TestCase
         $this->uploadFile($fileName, $page->id);
         $attachment = Attachment::orderBy('id', 'desc')->take(1)->first();
 
-        $page->restricted = true;
-        $page->permissions()->delete();
-        $page->save();
-        $page->rebuildPermissions();
-        $page->load('jointPermissions');
+        $this->entities->setPermissions($page, [], []);
 
         $this->actingAs($viewer);
         $attachmentGet = $this->get($attachment->getUrl());