diff --git a/app/Auth/Permissions/EntityPermission.php b/app/Auth/Permissions/EntityPermission.php index 131771a38..32ebc440d 100644 --- a/app/Auth/Permissions/EntityPermission.php +++ b/app/Auth/Permissions/EntityPermission.php @@ -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); + } } diff --git a/app/Auth/Permissions/JointPermissionBuilder.php b/app/Auth/Permissions/JointPermissionBuilder.php index f377eef5c..79903c027 100644 --- a/app/Auth/Permissions/JointPermissionBuilder.php +++ b/app/Auth/Permissions/JointPermissionBuilder.php @@ -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; } /** diff --git a/app/Auth/Permissions/PermissionApplicator.php b/app/Auth/Permissions/PermissionApplicator.php index d840ccd16..af372cb74 100644 --- a/app/Auth/Permissions/PermissionApplicator.php +++ b/app/Auth/Permissions/PermissionApplicator.php @@ -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 index 000000000..8044a3c56 --- /dev/null +++ b/app/Auth/Permissions/PermissionFormData.php @@ -0,0 +1,68 @@ +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'), + ]); + } +} diff --git a/app/Auth/Permissions/PermissionsRepo.php b/app/Auth/Permissions/PermissionsRepo.php index 2c2bedb72..6dcef7256 100644 --- a/app/Auth/Permissions/PermissionsRepo.php +++ b/app/Auth/Permissions/PermissionsRepo.php @@ -139,6 +139,7 @@ class PermissionsRepo } } + $role->entityPermissions()->delete(); $role->jointPermissions()->delete(); Activity::add(ActivityType::ROLE_DELETE, $role); $role->delete(); diff --git a/app/Auth/Permissions/SimpleEntityData.php b/app/Auth/Permissions/SimpleEntityData.php index 6ec0c4179..62f5984f8 100644 --- a/app/Auth/Permissions/SimpleEntityData.php +++ b/app/Auth/Permissions/SimpleEntityData.php @@ -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; diff --git a/app/Auth/Role.php b/app/Auth/Role.php index 51b2ce301..17a4edcc0 100644 --- a/app/Auth/Role.php +++ b/app/Auth/Role.php @@ -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} */ diff --git a/app/Console/Commands/CopyShelfPermissions.php b/app/Console/Commands/CopyShelfPermissions.php index 32adf0683..ec4c875ff 100644 --- a/app/Console/Commands/CopyShelfPermissions.php +++ b/app/Console/Commands/CopyShelfPermissions.php @@ -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 . ']'); } diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index bf42f2008..fc4556857 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -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(); + } } diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index cdc6648f9..ad52d9d37 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -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(); + } } diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index af4bbd8e3..98889ce3f 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -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(); + } } diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 26a52073e..8bfe69365 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -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; } /** diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index 93729d7f2..7a60b3ada 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -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(); + } } diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php index 6517b0080..cd22db0c8 100644 --- a/app/Entities/Models/PageRevision.php +++ b/app/Entities/Models/PageRevision.php @@ -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. diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index 1f144b1a8..d7759deb4 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -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. * diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php index 86f392e61..52a8f4cf0 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -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(); diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php index 50d9e2eae..43cf2390e 100644 --- a/app/Entities/Tools/HierarchyTransformer.php +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -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); } } diff --git a/app/Entities/Tools/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php index c771ee4b6..eb4eb6b48 100644 --- a/app/Entities/Tools/PermissionsUpdater.php +++ b/app/Entities/Tools/PermissionsUpdater.php @@ -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; } } diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index cc2f6f534..b323ae496 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -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. * diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index 2143b876a..3c63be631 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -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()); - } } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 6695c2868..4d2bcb2f1 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -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. */ diff --git a/app/Http/Controllers/FavouriteController.php b/app/Http/Controllers/FavouriteController.php index f77b04843..e46442a64 100644 --- a/app/Http/Controllers/FavouriteController.php +++ b/app/Http/Controllers/FavouriteController.php @@ -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) { diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 748468b21..9e09aed16 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -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 index 000000000..7d908733b --- /dev/null +++ b/app/Http/Controllers/PermissionsController.php @@ -0,0 +1,174 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/ReferenceController.php b/app/Http/Controllers/ReferenceController.php index 1daf1818c..b9b3e0eab 100644 --- a/app/Http/Controllers/ReferenceController.php +++ b/app/Http/Controllers/ReferenceController.php @@ -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', [ diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index e36edb06c..cc44e6125 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -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 index 000000000..468f33248 --- /dev/null +++ b/database/migrations/2022_10_07_091406_flatten_entity_permissions_table.php @@ -0,0 +1,105 @@ +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 index 000000000..063f924f2 --- /dev/null +++ b/database/migrations/2022_10_08_104202_drop_entity_restricted_field.php @@ -0,0 +1,93 @@ +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 index 000000000..c99a6b503 --- /dev/null +++ b/resources/icons/groups.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/role.svg b/resources/icons/role.svg new file mode 100644 index 000000000..e7cad506d --- /dev/null +++ b/resources/icons/role.svg @@ -0,0 +1,4 @@ + + + + \ 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 index a821792a0..000000000 --- a/resources/js/components/entity-permissions-editor.js +++ /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 index 000000000..917dcc72d --- /dev/null +++ b/resources/js/components/entity-permissions.js @@ -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 diff --git a/resources/js/components/index.js b/resources/js/components/index.js index f360e2b0c..7d00cb671 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -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, diff --git a/resources/js/components/permissions-table.js b/resources/js/components/permissions-table.js index baad75258..df3c055ca 100644 --- a/resources/js/components/permissions-table.js +++ b/resources/js/components/permissions-table.js @@ -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)); } } diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 1720801d2..bf6201900 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -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', diff --git a/resources/sass/_buttons.scss b/resources/sass/_buttons.scss index 714dfc42c..fb3af06e8 100644 --- a/resources/sass/_buttons.scss +++ b/resources/sass/_buttons.scss @@ -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; diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index c00f57954..9fdd5a611 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -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); } -.permissions-table tr:hover [permissions-table-toggle-all-in-row] { - display: inline; +.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; +} +.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 { diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 7025aa898..7e0f72355 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -207,8 +207,8 @@ select { -moz-appearance: none; appearance: none; background: url("data:image/svg+xml;utf8,"); - 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 { diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 2cd57d496..cfb8397c9 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -158,8 +158,8 @@ body.flexbox { } } -.gap-m { - gap: $-m; +.flex-none { + flex: none; } .justify-flex-start { diff --git a/resources/sass/_spacing.scss b/resources/sass/_spacing.scss index 40217de9b..14f8918dc 100644 --- a/resources/sass/_spacing.scss +++ b/resources/sass/_spacing.scss @@ -29,4 +29,16 @@ } } @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; + } +} diff --git a/resources/views/books/permissions.blade.php b/resources/views/books/permissions.blade.php index d72042d42..2e43338cd 100644 --- a/resources/views/books/permissions.blade.php +++ b/resources/views/books/permissions.blade.php @@ -14,9 +14,8 @@ ]]) -
-

{{ trans('entities.books_permissions') }}

- @include('form.entity-permissions', ['model' => $book]) +
+ @include('form.entity-permissions', ['model' => $book, 'title' => trans('entities.books_permissions')])
diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index 76a4a6005..b95b69d1b 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -71,7 +71,7 @@
{{ trans('common.details') }}
diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index 1ae2d6847..b3496eae2 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -69,7 +69,7 @@