diff --git a/app/Actions/Comment.php b/app/Actions/Comment.php index 655d45221..f5269e253 100644 --- a/app/Actions/Comment.php +++ b/app/Actions/Comment.php @@ -1,6 +1,8 @@ <?php namespace BookStack\Actions; -use BookStack\Ownable; +use BookStack\Model; +use BookStack\Traits\HasCreatorAndUpdater; +use Illuminate\Database\Eloquent\Relations\MorphTo; /** * @property string text @@ -8,25 +10,25 @@ use BookStack\Ownable; * @property int|null parent_id * @property int local_id */ -class Comment extends Ownable +class Comment extends Model { + use HasCreatorAndUpdater; + protected $fillable = ['text', 'parent_id']; protected $appends = ['created', 'updated']; /** * Get the entity that this comment belongs to - * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ - public function entity() + public function entity(): MorphTo { return $this->morphTo('entity'); } /** * Check if a comment has been updated since creation. - * @return bool */ - public function isUpdated() + public function isUpdated(): bool { return $this->updated_at->timestamp > $this->created_at->timestamp; } diff --git a/app/Auth/Permissions/PermissionService.php b/app/Auth/Permissions/PermissionService.php index 5f4648d58..d858a7c18 100644 --- a/app/Auth/Permissions/PermissionService.php +++ b/app/Auth/Permissions/PermissionService.php @@ -5,7 +5,9 @@ use BookStack\Auth\Role; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Entity; use BookStack\Entities\EntityProvider; -use BookStack\Ownable; +use BookStack\Model; +use BookStack\Traits\HasCreatorAndUpdater; +use BookStack\Traits\HasOwner; use Illuminate\Database\Connection; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\Builder as QueryBuilder; @@ -168,7 +170,7 @@ class PermissionService }); // Chunk through all bookshelves - $this->entityProvider->bookshelf->newQuery()->withTrashed()->select(['id', 'restricted', 'created_by']) + $this->entityProvider->bookshelf->newQuery()->withTrashed()->select(['id', 'restricted', 'owned_by']) ->chunk(50, function ($shelves) use ($roles) { $this->buildJointPermissionsForShelves($shelves, $roles); }); @@ -181,10 +183,10 @@ class PermissionService protected function bookFetchQuery() { return $this->entityProvider->book->withTrashed()->newQuery() - ->select(['id', 'restricted', 'created_by'])->with(['chapters' => function ($query) { - $query->withTrashed()->select(['id', 'restricted', 'created_by', 'book_id']); + ->select(['id', 'restricted', 'owned_by'])->with(['chapters' => function ($query) { + $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']); }, 'pages' => function ($query) { - $query->withTrashed()->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']); + $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']); }]); } @@ -286,7 +288,7 @@ class PermissionService }); // Chunk through all bookshelves - $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by']) + $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'owned_by']) ->chunk(50, function ($shelves) use ($roles) { $this->buildJointPermissionsForShelves($shelves, $roles); }); @@ -508,21 +510,19 @@ class PermissionService 'action' => $action, 'has_permission' => $permissionAll, 'has_permission_own' => $permissionOwn, - 'created_by' => $entity->getRawAttribute('created_by') + 'owned_by' => $entity->getRawAttribute('owned_by') ]; } /** * Checks if an entity has a restriction set upon it. - * @param Ownable $ownable - * @param $permission - * @return bool + * @param HasCreatorAndUpdater|HasOwner $ownable */ - public function checkOwnableUserAccess(Ownable $ownable, $permission) + public function checkOwnableUserAccess(Model $ownable, string $permission): bool { $explodedPermission = explode('-', $permission); - $baseQuery = $ownable->where('id', '=', $ownable->id); + $baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id); $action = end($explodedPermission); $this->currentAction = $action; @@ -566,7 +566,7 @@ class PermissionService $query->where('has_permission', '=', 1) ->orWhere(function ($query2) use ($userId) { $query2->where('has_permission_own', '=', 1) - ->where('created_by', '=', $userId); + ->where('owned_by', '=', $userId); }); }); @@ -615,7 +615,7 @@ class PermissionService $query->where('has_permission', '=', true) ->orWhere(function ($query) { $query->where('has_permission_own', '=', true) - ->where('created_by', '=', $this->currentUser()->id); + ->where('owned_by', '=', $this->currentUser()->id); }); }); }); @@ -639,7 +639,7 @@ class PermissionService $query->where('has_permission', '=', true) ->orWhere(function (Builder $query) { $query->where('has_permission_own', '=', true) - ->where('created_by', '=', $this->currentUser()->id); + ->where('owned_by', '=', $this->currentUser()->id); }); }); }); @@ -656,7 +656,7 @@ class PermissionService $query->where('draft', '=', false) ->orWhere(function (Builder $query) { $query->where('draft', '=', true) - ->where('created_by', '=', $this->currentUser()->id); + ->where('owned_by', '=', $this->currentUser()->id); }); }); } @@ -676,7 +676,7 @@ class PermissionService $query->where('draft', '=', false) ->orWhere(function ($query) { $query->where('draft', '=', true) - ->where('created_by', '=', $this->currentUser()->id); + ->where('owned_by', '=', $this->currentUser()->id); }); }); } @@ -710,7 +710,7 @@ class PermissionService ->where(function ($query) { $query->where('has_permission', '=', true)->orWhere(function ($query) { $query->where('has_permission_own', '=', true) - ->where('created_by', '=', $this->currentUser()->id); + ->where('owned_by', '=', $this->currentUser()->id); }); }); }); @@ -746,7 +746,7 @@ class PermissionService ->where(function ($query) { $query->where('has_permission', '=', true)->orWhere(function ($query) { $query->where('has_permission_own', '=', true) - ->where('created_by', '=', $this->currentUser()->id); + ->where('owned_by', '=', $this->currentUser()->id); }); }); }); diff --git a/app/Auth/UserRepo.php b/app/Auth/UserRepo.php index 6b7de3259..6fb5dfa0f 100644 --- a/app/Auth/UserRepo.php +++ b/app/Auth/UserRepo.php @@ -1,6 +1,7 @@ <?php namespace BookStack\Auth; use Activity; +use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; @@ -169,7 +170,7 @@ class UserRepo * Remove the given user from storage, Delete all related content. * @throws Exception */ - public function destroy(User $user) + public function destroy(User $user, ?int $newOwnerId = null) { $user->socialAccounts()->delete(); $user->apiTokens()->delete(); @@ -183,6 +184,25 @@ class UserRepo foreach ($profileImages as $image) { Images::destroy($image); } + + if (!empty($newOwnerId)) { + $newOwner = User::query()->find($newOwnerId); + if (!is_null($newOwner)) { + $this->migrateOwnership($user, $newOwner); + } + } + } + + /** + * Migrate ownership of items in the system from one user to another. + */ + protected function migrateOwnership(User $fromUser, User $toUser) + { + $entities = (new EntityProvider)->all(); + foreach ($entities as $instance) { + $instance->newQuery()->where('owned_by', '=', $fromUser->id) + ->update(['owned_by' => $toUser->id]); + } } /** diff --git a/app/Entities/EntityProvider.php b/app/Entities/EntityProvider.php index ef1935a0f..c77a57d61 100644 --- a/app/Entities/EntityProvider.php +++ b/app/Entities/EntityProvider.php @@ -55,7 +55,7 @@ class EntityProvider /** * Fetch all core entity types as an associated array * with their basic names as the keys. - * @return [string => Entity] + * @return array<Entity> */ public function all(): array { diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index e681a4e22..c6b2468b0 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -9,7 +9,9 @@ use BookStack\Auth\Permissions\JointPermission; use BookStack\Entities\Tools\SearchIndex; use BookStack\Entities\Tools\SlugGenerator; use BookStack\Facades\Permissions; -use BookStack\Ownable; +use BookStack\Model; +use BookStack\Traits\HasCreatorAndUpdater; +use BookStack\Traits\HasOwner; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -35,9 +37,11 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @method static Builder withLastView() * @method static Builder withViewCount() */ -abstract class Entity extends Ownable +abstract class Entity extends Model { use SoftDeletes; + use HasCreatorAndUpdater; + use HasOwner; /** * @var string - Name of property where the main text content is found diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index ff4fc635b..8b2e70074 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -4,6 +4,7 @@ namespace BookStack\Entities\Repos; use BookStack\Actions\ActivityType; use BookStack\Actions\TagRepo; +use BookStack\Auth\User; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\HasCoverImage; use BookStack\Exceptions\ImageUploadException; @@ -34,6 +35,7 @@ class BaseRepo $entity->forceFill([ 'created_by' => user()->id, 'updated_by' => user()->id, + 'owned_by' => user()->id, ]); $entity->refreshSlug(); $entity->save(); @@ -88,30 +90,4 @@ class BaseRepo $entity->save(); } } - - /** - * Update the permissions of an entity. - */ - public function updatePermissions(Entity $entity, bool $restricted, Collection $permissions = null) - { - $entity->restricted = $restricted; - $entity->permissions()->delete(); - - if (!is_null($permissions)) { - $entityPermissionData = $permissions->flatMap(function ($restrictions, $roleId) { - return collect($restrictions)->keys()->map(function ($action) use ($roleId) { - return [ - 'role_id' => $roleId, - 'action' => strtolower($action), - ] ; - }); - }); - - $entity->permissions()->createMany($entityPermissionData); - } - - $entity->save(); - $entity->rebuildPermissions(); - Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE); - } } diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index d6dbe0b73..68d62887b 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -114,14 +114,6 @@ class BookRepo $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage); } - /** - * Update the permissions of a book. - */ - public function updatePermissions(Book $book, bool $restricted, Collection $permissions = null) - { - $this->baseRepo->updatePermissions($book, $restricted, $permissions); - } - /** * Remove a book from the system. * @throws Exception diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index 075582cbf..b15241fb3 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -137,14 +137,6 @@ class BookshelfRepo $this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage); } - /** - * Update the permissions of a bookshelf. - */ - public function updatePermissions(Bookshelf $shelf, bool $restricted, Collection $permissions = null) - { - $this->baseRepo->updatePermissions($shelf, $restricted, $permissions); - } - /** * Copy down the permissions of the given shelf to all child books. */ diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index 281cc2cab..d56874e0d 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -62,14 +62,6 @@ class ChapterRepo return $chapter; } - /** - * Update the permissions of a chapter. - */ - public function updatePermissions(Chapter $chapter, bool $restricted, Collection $permissions = null) - { - $this->baseRepo->updatePermissions($chapter, $restricted, $permissions); - } - /** * Remove a chapter from the system. * @throws Exception diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 153ef8575..8840c06db 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -130,6 +130,7 @@ class PageRepo $page = (new Page())->forceFill([ 'name' => trans('entities.pages_initial_name'), 'created_by' => user()->id, + 'owned_by' => user()->id, 'updated_by' => user()->id, 'draft' => true, ]); @@ -382,14 +383,6 @@ class PageRepo return $parentClass::visible()->where('id', '=', $entityId)->first(); } - /** - * Update the permissions of a page. - */ - public function updatePermissions(Page $page, bool $restricted, Collection $permissions = null) - { - $this->baseRepo->updatePermissions($page, $restricted, $permissions); - } - /** * Change the page's parent to the given entity. */ diff --git a/app/Entities/Tools/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php new file mode 100644 index 000000000..8a27ce75b --- /dev/null +++ b/app/Entities/Tools/PermissionsUpdater.php @@ -0,0 +1,68 @@ +<?php namespace BookStack\Entities\Tools; + +use BookStack\Actions\ActivityType; +use BookStack\Auth\User; +use BookStack\Entities\Models\Entity; +use BookStack\Facades\Activity; +use Illuminate\Http\Request; +use Illuminate\Support\Collection; + +class PermissionsUpdater +{ + + /** + * Update an entities permissions from a permission form submit request. + */ + public function updateFromPermissionsForm(Entity $entity, Request $request) + { + $restricted = $request->get('restricted') === 'true'; + $permissions = $request->get('restrictions', null); + $ownerId = $request->get('owned_by', null); + + $entity->restricted = $restricted; + $entity->permissions()->delete(); + + if (!is_null($permissions)) { + $entityPermissionData = $this->formatPermissionsFromRequestToEntityPermissions($permissions); + $entity->permissions()->createMany($entityPermissionData); + } + + if (!is_null($ownerId)) { + $this->updateOwnerFromId($entity, intval($ownerId)); + } + + $entity->save(); + $entity->rebuildPermissions(); + + Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE); + } + + /** + * Update the owner of the given entity. + * Checks the user exists in the system first. + * Does not save the model, just updates it. + */ + protected function updateOwnerFromId(Entity $entity, int $newOwnerId) + { + $newOwner = User::query()->find($newOwnerId); + if (!is_null($newOwner)) { + $entity->owned_by = $newOwner->id; + } + } + + /** + * Format permissions provided from a permission form to be + * EntityPermission data. + */ + protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): Collection + { + return collect($permissions)->flatMap(function ($restrictions, $roleId) { + return collect($restrictions)->keys()->map(function ($action) use ($roleId) { + return [ + 'role_id' => $roleId, + 'action' => strtolower($action), + ] ; + }); + }); + } +} diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index b63fe911f..3d695ba85 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -4,6 +4,7 @@ use Activity; use BookStack\Actions\ActivityType; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Models\Bookshelf; +use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Entities\Tools\ShelfContext; use BookStack\Entities\Repos\BookRepo; use BookStack\Exceptions\ImageUploadException; @@ -202,14 +203,12 @@ class BookController extends Controller * Set the restrictions for this book. * @throws Throwable */ - public function permissions(Request $request, string $bookSlug) + public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug) { $book = $this->bookRepo->getBySlug($bookSlug); $this->checkOwnablePermission('restrictions-manage', $book); - $restricted = $request->get('restricted') === 'true'; - $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null; - $this->bookRepo->updatePermissions($book, $restricted, $permissions); + $permissionsUpdater->updateFromPermissionsForm($book, $request); $this->showSuccessNotification(trans('entities.books_permissions_updated')); return redirect($book->getUrl()); diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index 50dc97bab..32c22e185 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -2,6 +2,7 @@ use Activity; use BookStack\Entities\Models\Book; +use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Entities\Tools\ShelfContext; use BookStack\Entities\Repos\BookshelfRepo; use BookStack\Exceptions\ImageUploadException; @@ -19,9 +20,6 @@ class BookshelfController extends Controller protected $entityContextManager; protected $imageRepo; - /** - * BookController constructor. - */ public function __construct(BookshelfRepo $bookshelfRepo, ShelfContext $entityContextManager, ImageRepo $imageRepo) { $this->bookshelfRepo = $bookshelfRepo; @@ -200,14 +198,12 @@ class BookshelfController extends Controller /** * Set the permissions for this bookshelf. */ - public function permissions(Request $request, string $slug) + public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $slug) { $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('restrictions-manage', $shelf); - $restricted = $request->get('restricted') === 'true'; - $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null; - $this->bookshelfRepo->updatePermissions($shelf, $restricted, $permissions); + $permissionsUpdater->updateFromPermissionsForm($shelf, $request); $this->showSuccessNotification(trans('entities.shelves_permissions_updated')); return redirect($shelf->getUrl()); diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 0059f202b..1d69df2a2 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -3,6 +3,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Repos\ChapterRepo; +use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\NotFoundException; use Illuminate\Http\Request; @@ -190,14 +191,12 @@ class ChapterController extends Controller * Set the restrictions for this chapter. * @throws NotFoundException */ - public function permissions(Request $request, string $bookSlug, string $chapterSlug) + public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $chapterSlug) { $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->checkOwnablePermission('restrictions-manage', $chapter); - $restricted = $request->get('restricted') === 'true'; - $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null; - $this->chapterRepo->updatePermissions($chapter, $restricted, $permissions); + $permissionsUpdater->updateFromPermissionsForm($chapter, $request); $this->showSuccessNotification(trans('entities.chapters_permissions_success')); return redirect($chapter->getUrl()); diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 758c85dda..479d5ac15 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -4,7 +4,8 @@ namespace BookStack\Http\Controllers; use BookStack\Facades\Activity; use BookStack\Interfaces\Loggable; -use BookStack\Ownable; +use BookStack\HasCreatorAndUpdater; +use BookStack\Model; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\Exceptions\HttpResponseException; @@ -72,7 +73,7 @@ abstract class Controller extends BaseController /** * Check the current user's permissions against an ownable item otherwise throw an exception. */ - protected function checkOwnablePermission(string $permission, Ownable $ownable): void + protected function checkOwnablePermission(string $permission, Model $ownable): void { if (!userCan($permission, $ownable)) { $this->showPermissionError(); diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 67d28a78a..7d8e54382 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -5,6 +5,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageEditActivity; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\PageRepo; +use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\PermissionsException; @@ -453,14 +454,12 @@ class PageController extends Controller * @throws NotFoundException * @throws Throwable */ - public function permissions(Request $request, string $bookSlug, string $pageSlug) + public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $pageSlug) { $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('restrictions-manage', $page); - $restricted = $request->get('restricted') === 'true'; - $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null; - $this->pageRepo->updatePermissions($page, $restricted, $permissions); + $permissionsUpdater->updateFromPermissionsForm($page, $request); $this->showSuccessNotification(trans('entities.pages_permissions_success')); return redirect($page->getUrl()); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 8d688ed84..852d507c1 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -217,12 +217,13 @@ class UserController extends Controller * Remove the specified user from storage. * @throws \Exception */ - public function destroy(int $id) + public function destroy(Request $request, int $id) { $this->preventAccessInDemoMode(); $this->checkPermissionOrCurrentUser('users-manage', $id); $user = $this->userRepo->getById($id); + $newOwnerId = $request->get('new_owner_id', null); if ($this->userRepo->isOnlyAdmin($user)) { $this->showErrorNotification(trans('errors.users_cannot_delete_only_admin')); @@ -234,7 +235,7 @@ class UserController extends Controller return redirect($user->getEditUrl()); } - $this->userRepo->destroy($user); + $this->userRepo->destroy($user, $newOwnerId); $this->showSuccessNotification(trans('settings.users_delete_success')); $this->logActivity(ActivityType::USER_DELETE, $user); diff --git a/app/Http/Controllers/UserSearchController.php b/app/Http/Controllers/UserSearchController.php new file mode 100644 index 000000000..a0dfbd8d0 --- /dev/null +++ b/app/Http/Controllers/UserSearchController.php @@ -0,0 +1,31 @@ +<?php + +namespace BookStack\Http\Controllers; + +use BookStack\Auth\User; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\Request; + +class UserSearchController extends Controller +{ + /** + * Search users in the system, with the response formatted + * for use in a select-style list. + */ + public function forSelect(Request $request) + { + $search = $request->get('search', ''); + $query = User::query()->orderBy('name', 'desc') + ->take(20); + + if (!empty($search)) { + $query->where(function (Builder $query) use ($search) { + $query->where('email', 'like', '%' . $search . '%') + ->orWhere('name', 'like', '%' . $search . '%'); + }); + } + + $users = $query->get(); + return view('components.user-select-list', compact('users')); + } +} diff --git a/app/Ownable.php b/app/Traits/HasCreatorAndUpdater.php similarity index 59% rename from app/Ownable.php rename to app/Traits/HasCreatorAndUpdater.php index b118bc742..ad6c3035f 100644 --- a/app/Ownable.php +++ b/app/Traits/HasCreatorAndUpdater.php @@ -1,27 +1,26 @@ -<?php namespace BookStack; +<?php namespace BookStack\Traits; use BookStack\Auth\User; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property int created_by * @property int updated_by */ -abstract class Ownable extends Model +trait HasCreatorAndUpdater { /** * Relation for the user that created this entity. - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ - public function createdBy() + public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); } /** * Relation for the user that updated this entity. - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ - public function updatedBy() + public function updatedBy(): BelongsTo { return $this->belongsTo(User::class, 'updated_by'); } diff --git a/app/Traits/HasOwner.php b/app/Traits/HasOwner.php new file mode 100644 index 000000000..9d1eb3df7 --- /dev/null +++ b/app/Traits/HasOwner.php @@ -0,0 +1,19 @@ +<?php namespace BookStack\Traits; + +use BookStack\Auth\User; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * @property int owned_by + */ +trait HasOwner +{ + /** + * Relation for the user that owns this entity. + */ + public function ownedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'owned_by'); + } + +} diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 77c7925db..d1060477d 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -1,7 +1,8 @@ <?php namespace BookStack\Uploads; use BookStack\Entities\Models\Page; -use BookStack\Ownable; +use BookStack\Model; +use BookStack\Traits\HasCreatorAndUpdater; /** * @property int id @@ -10,8 +11,10 @@ use BookStack\Ownable; * @property string extension * @property bool external */ -class Attachment extends Ownable +class Attachment extends Model { + use HasCreatorAndUpdater; + protected $fillable = ['name', 'order']; /** diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index 029fd3175..dc26af002 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -1,11 +1,13 @@ <?php namespace BookStack\Uploads; use BookStack\Entities\Models\Page; -use BookStack\Ownable; +use BookStack\Model; +use BookStack\Traits\HasCreatorAndUpdater; use Images; -class Image extends Ownable +class Image extends Model { + use HasCreatorAndUpdater; protected $fillable = ['name']; protected $hidden = []; diff --git a/app/helpers.php b/app/helpers.php index 935d4d8da..c090bfd05 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -2,7 +2,7 @@ use BookStack\Auth\Permissions\PermissionService; use BookStack\Auth\User; -use BookStack\Ownable; +use BookStack\Model; use BookStack\Settings\SettingService; /** @@ -56,7 +56,7 @@ function hasAppAccess(): bool * Check if the current user has a permission. If an ownable element * is passed in the jointPermissions are checked against that particular item. */ -function userCan(string $permission, Ownable $ownable = null): bool +function userCan(string $permission, Model $ownable = null): bool { if ($ownable === null) { return user() && user()->can($permission); diff --git a/database/migrations/2020_12_30_173528_add_owned_by_field_to_entities.php b/database/migrations/2020_12_30_173528_add_owned_by_field_to_entities.php new file mode 100644 index 000000000..bf8bf281f --- /dev/null +++ b/database/migrations/2020_12_30_173528_add_owned_by_field_to_entities.php @@ -0,0 +1,49 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\DB; + +class AddOwnedByFieldToEntities extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + $tables = ['pages', 'books', 'chapters', 'bookshelves']; + foreach ($tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->integer('owned_by')->unsigned()->index(); + }); + + DB::table($table)->update(['owned_by' => DB::raw('`created_by`')]); + } + + Schema::table('joint_permissions', function (Blueprint $table) { + $table->renameColumn('created_by', 'owned_by'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $tables = ['pages', 'books', 'chapters', 'bookshelves']; + foreach ($tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('owned_by'); + }); + } + + Schema::table('joint_permissions', function (Blueprint $table) { + $table->renameColumn('owned_by', 'created_by'); + }); + } +} diff --git a/database/seeds/DummyContentSeeder.php b/database/seeds/DummyContentSeeder.php index 55e1f1075..611c05246 100644 --- a/database/seeds/DummyContentSeeder.php +++ b/database/seeds/DummyContentSeeder.php @@ -31,7 +31,7 @@ class DummyContentSeeder extends Seeder $role = Role::getRole('viewer'); $viewerUser->attachRole($role); - $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]; + $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id]; factory(\BookStack\Entities\Models\Book::class, 5)->create($byData) ->each(function($book) use ($editorUser, $byData) { diff --git a/resources/js/components/breadcrumb-listing.js b/resources/js/components/breadcrumb-listing.js deleted file mode 100644 index 7f4344b17..000000000 --- a/resources/js/components/breadcrumb-listing.js +++ /dev/null @@ -1,58 +0,0 @@ - - -class BreadcrumbListing { - - constructor(elem) { - this.elem = elem; - this.searchInput = elem.querySelector('input'); - this.loadingElem = elem.querySelector('.loading-container'); - this.entityListElem = elem.querySelector('.breadcrumb-listing-entity-list'); - - // this.loadingElem.style.display = 'none'; - const entityDescriptor = elem.getAttribute('breadcrumb-listing').split(':'); - this.entityType = entityDescriptor[0]; - this.entityId = Number(entityDescriptor[1]); - - this.elem.addEventListener('show', this.onShow.bind(this)); - this.searchInput.addEventListener('input', this.onSearch.bind(this)); - } - - onShow() { - this.loadEntityView(); - } - - onSearch() { - const input = this.searchInput.value.toLowerCase().trim(); - const listItems = this.entityListElem.querySelectorAll('.entity-list-item'); - for (let listItem of listItems) { - const match = !input || listItem.textContent.toLowerCase().includes(input); - listItem.style.display = match ? 'flex' : 'none'; - listItem.classList.toggle('hidden', !match); - } - } - - loadEntityView() { - this.toggleLoading(true); - - const params = { - 'entity_id': this.entityId, - 'entity_type': this.entityType, - }; - - window.$http.get('/search/entity/siblings', params).then(resp => { - this.entityListElem.innerHTML = resp.data; - }).catch(err => { - console.error(err); - }).then(() => { - this.toggleLoading(false); - this.onSearch(); - }); - } - - toggleLoading(show = false) { - this.loadingElem.style.display = show ? 'block' : 'none'; - } - -} - -export default BreadcrumbListing; \ No newline at end of file diff --git a/resources/js/components/dropdown-search.js b/resources/js/components/dropdown-search.js new file mode 100644 index 000000000..8c81aae3c --- /dev/null +++ b/resources/js/components/dropdown-search.js @@ -0,0 +1,79 @@ +import {debounce} from "../services/util"; + +class DropdownSearch { + + setup() { + this.elem = this.$el; + this.searchInput = this.$refs.searchInput; + this.loadingElem = this.$refs.loading; + this.listContainerElem = this.$refs.listContainer; + + this.localSearchSelector = this.$opts.localSearchSelector; + this.url = this.$opts.url; + + this.elem.addEventListener('show', this.onShow.bind(this)); + this.searchInput.addEventListener('input', this.onSearch.bind(this)); + + this.runAjaxSearch = debounce(this.runAjaxSearch, 300, false); + } + + onShow() { + this.loadList(); + } + + onSearch() { + const input = this.searchInput.value.toLowerCase().trim(); + if (this.localSearchSelector) { + this.runLocalSearch(input); + } else { + this.toggleLoading(true); + this.runAjaxSearch(input); + } + } + + runAjaxSearch(searchTerm) { + this.loadList(searchTerm); + } + + runLocalSearch(searchTerm) { + const listItems = this.listContainerElem.querySelectorAll(this.localSearchSelector); + for (let listItem of listItems) { + const match = !searchTerm || listItem.textContent.toLowerCase().includes(searchTerm); + listItem.style.display = match ? 'flex' : 'none'; + listItem.classList.toggle('hidden', !match); + } + } + + async loadList(searchTerm = '') { + this.listContainerElem.innerHTML = ''; + this.toggleLoading(true); + + try { + const resp = await window.$http.get(this.getAjaxUrl(searchTerm)); + this.listContainerElem.innerHTML = resp.data; + } catch (err) { + console.error(err); + } + + this.toggleLoading(false); + if (this.localSearchSelector) { + this.onSearch(); + } + } + + getAjaxUrl(searchTerm = null) { + if (!searchTerm) { + return this.url; + } + + const joiner = this.url.includes('?') ? '&' : '?'; + return `${this.url}${joiner}search=${encodeURIComponent(searchTerm)}`; + } + + toggleLoading(show = false) { + this.loadingElem.style.display = show ? 'block' : 'none'; + } + +} + +export default DropdownSearch; \ No newline at end of file diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index 7b1ce3055..22402d483 100644 --- a/resources/js/components/dropdown.js +++ b/resources/js/components/dropdown.js @@ -17,6 +17,7 @@ class DropDown { this.body = document.body; this.showing = false; this.setupListeners(); + this.hide = this.hide.bind(this); } show(event = null) { diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 87c496c91..91ccdaf3a 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -5,7 +5,6 @@ import attachments from "./attachments.js" import autoSuggest from "./auto-suggest.js" import backToTop from "./back-to-top.js" import bookSort from "./book-sort.js" -import breadcrumbListing from "./breadcrumb-listing.js" import chapterToggle from "./chapter-toggle.js" import codeEditor from "./code-editor.js" import codeHighlighter from "./code-highlighter.js" @@ -13,6 +12,7 @@ import collapsible from "./collapsible.js" import customCheckbox from "./custom-checkbox.js" import detailsHighlighter from "./details-highlighter.js" 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" @@ -48,6 +48,7 @@ import tagManager from "./tag-manager.js" import templateManager from "./template-manager.js" import toggleSwitch from "./toggle-switch.js" import triLayout from "./tri-layout.js" +import userSelect from "./user-select.js" import wysiwygEditor from "./wysiwyg-editor.js" const componentMapping = { @@ -58,7 +59,6 @@ const componentMapping = { "auto-suggest": autoSuggest, "back-to-top": backToTop, "book-sort": bookSort, - "breadcrumb-listing": breadcrumbListing, "chapter-toggle": chapterToggle, "code-editor": codeEditor, "code-highlighter": codeHighlighter, @@ -66,6 +66,7 @@ const componentMapping = { "custom-checkbox": customCheckbox, "details-highlighter": detailsHighlighter, "dropdown": dropdown, + "dropdown-search": dropdownSearch, "dropzone": dropzone, "editor-toolbox": editorToolbox, "entity-permissions-editor": entityPermissionsEditor, @@ -101,6 +102,7 @@ const componentMapping = { "template-manager": templateManager, "toggle-switch": toggleSwitch, "tri-layout": triLayout, + "user-select": userSelect, "wysiwyg-editor": wysiwygEditor, }; diff --git a/resources/js/components/user-select.js b/resources/js/components/user-select.js new file mode 100644 index 000000000..477c11d6b --- /dev/null +++ b/resources/js/components/user-select.js @@ -0,0 +1,24 @@ +import {onChildEvent} from "../services/dom"; + +class UserSelect { + + setup() { + + this.input = this.$refs.input; + this.userInfoContainer = this.$refs.userInfo; + + this.hide = this.$el.components.dropdown.hide; + + onChildEvent(this.$el, 'a.dropdown-search-item', 'click', this.selectUser.bind(this)); + } + + selectUser(event, userEl) { + const id = userEl.getAttribute('data-id'); + this.input.value = id; + this.userInfoContainer.innerHTML = userEl.innerHTML; + this.hide(); + } + +} + +export default UserSelect; \ No newline at end of file diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 485ecb7bc..6b0153844 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -22,6 +22,7 @@ return [ 'meta_created_name' => 'Created :timeLength by :user', 'meta_updated' => 'Updated :timeLength', 'meta_updated_name' => 'Updated :timeLength by :user', + 'meta_owned_name' => 'Owned by :user', 'entity_select' => 'Entity Select', 'images' => 'Images', 'my_recent_drafts' => 'My Recent Drafts', @@ -39,6 +40,7 @@ return [ 'permissions_intro' => 'Once enabled, These permissions will take priority over any set role permissions.', 'permissions_enable' => 'Enable Custom Permissions', 'permissions_save' => 'Save Permissions', + 'permissions_owner' => 'Owner', // Search 'search_results' => 'Search Results', diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 3e043e3c6..fe7ebc612 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -175,7 +175,10 @@ return [ 'users_delete_named' => 'Delete user :userName', 'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.', 'users_delete_confirm' => 'Are you sure you want to delete this user?', - 'users_delete_success' => 'Users successfully removed', + 'users_migrate_ownership' => 'Migrate Ownership', + 'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.', + 'users_none_selected' => 'No user selected', + 'users_delete_success' => 'User successfully removed', 'users_edit' => 'Edit User', 'users_edit_profile' => 'Edit Profile', 'users_edit_success' => 'User successfully updated', diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index eb40741d1..ede26c51c 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -724,4 +724,65 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .template-item-actions button:first-child { border-top: 0; } +} + +.dropdown-search-dropdown { + box-shadow: $bs-med; + overflow: hidden; + min-height: 100px; + width: 240px; + display: none; + position: absolute; + z-index: 80; + right: -$-m; + @include rtl { + right: auto; + left: -$-m; + } + .dropdown-search-search .svg-icon { + position: absolute; + left: $-s; + @include rtl { + right: $-s; + left: auto; + } + top: 11px; + fill: #888; + pointer-events: none; + } + .dropdown-search-list { + max-height: 400px; + overflow-y: scroll; + text-align: start; + } + .dropdown-search-item { + padding: $-s $-m; + &:hover,&:focus { + background-color: #F2F2F2; + text-decoration: none; + } + } + input { + padding-inline-start: $-xl; + border-radius: 0; + border: 0; + border-bottom: 1px solid #DDD; + } +} + +@include smaller-than($m) { + .dropdown-search-dropdown { + position: fixed; + right: auto; + left: $-m; + } + .dropdown-search-dropdown .dropdown-search-list { + max-height: 240px; + } +} + +.custom-select-input { + max-width: 280px; + border: 1px solid #DDD; + border-radius: 4px; } \ No newline at end of file diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss index e19bb4f61..246ef4b5b 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -269,9 +269,9 @@ header .search-box { } } -.breadcrumb-listing { +.dropdown-search { position: relative; - .breadcrumb-listing-toggle { + .dropdown-search-toggle { padding: 6px; border: 1px solid transparent; border-radius: 4px; @@ -284,54 +284,6 @@ header .search-box { } } -.breadcrumb-listing-dropdown { - box-shadow: $bs-med; - overflow: hidden; - min-height: 100px; - width: 240px; - display: none; - position: absolute; - z-index: 80; - right: -$-m; - @include rtl { - right: auto; - left: -$-m; - } - .breadcrumb-listing-search .svg-icon { - position: absolute; - left: $-s; - @include rtl { - right: $-s; - left: auto; - } - top: 11px; - fill: #888; - pointer-events: none; - } - .breadcrumb-listing-entity-list { - max-height: 400px; - overflow-y: scroll; - text-align: start; - } - input { - padding-inline-start: $-xl; - border-radius: 0; - border: 0; - border-bottom: 1px solid #DDD; - } -} - -@include smaller-than($m) { - .breadcrumb-listing-dropdown { - position: fixed; - right: auto; - left: $-m; - } - .breadcrumb-listing-dropdown .breadcrumb-listing-entity-list { - max-height: 240px; - } -} - .faded { a, button, span, span > div { color: #666; diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index c4e412f0e..e5ed608eb 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -153,6 +153,9 @@ body.flexbox { .justify-center { justify-content: center; } +.items-center { + align-items: center; +} /** diff --git a/resources/views/components/user-select-list.blade.php b/resources/views/components/user-select-list.blade.php new file mode 100644 index 000000000..2c49e965d --- /dev/null +++ b/resources/views/components/user-select-list.blade.php @@ -0,0 +1,6 @@ +@foreach($users as $user) + <a href="#" class="flex-container-row items-center dropdown-search-item" data-id="{{ $user->id }}"> + <img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}"> + <span>{{ $user->name }}</span> + </a> +@endforeach \ No newline at end of file diff --git a/resources/views/components/user-select.blade.php b/resources/views/components/user-select.blade.php new file mode 100644 index 000000000..2a07f0bde --- /dev/null +++ b/resources/views/components/user-select.blade.php @@ -0,0 +1,34 @@ +<div class="dropdown-search custom-select-input" components="dropdown dropdown-search user-select" + option:dropdown-search:url="/search/users/select" +> + <input refs="user-select@input" type="hidden" name="{{ $name }}" value="{{ $user->id ?? '' }}"> + <div refs="dropdown@toggle" + class="dropdown-search-toggle flex-container-row items-center" + aria-haspopup="true" aria-expanded="false" tabindex="0"> + <div refs="user-select@user-info" class="flex-container-row items-center px-s"> + @if($user) + <img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}"> + <span>{{ $user->name }}</span> + @else + <span>{{ trans('settings.users_none_selected') }}</span> + @endif + </div> + <span style="font-size: 1.5rem; margin-left: auto;"> + @icon('caret-down') + </span> + </div> + <div refs="dropdown@menu" class="dropdown-search-dropdown card" role="menu"> + <div class="dropdown-search-search"> + @icon('search') + <input refs="dropdown-search@searchInput" + aria-label="{{ trans('common.search') }}" + autocomplete="off" + placeholder="{{ trans('common.search') }}" + type="text"> + </div> + <div refs="dropdown-search@loading" class="text-center"> + @include('partials.loading-icon') + </div> + <div refs="dropdown-search@listContainer" class="dropdown-search-list"></div> + </div> +</div> \ No newline at end of file diff --git a/resources/views/form/entity-permissions.blade.php b/resources/views/form/entity-permissions.blade.php index 3581a545b..35490bed9 100644 --- a/resources/views/form/entity-permissions.blade.php +++ b/resources/views/form/entity-permissions.blade.php @@ -2,15 +2,26 @@ {!! csrf_field() !!} <input type="hidden" name="_method" value="PUT"> - <p class="mb-none">{{ trans('entities.permissions_intro') }}</p> - - <div class="form-group"> - @include('form.checkbox', [ - 'name' => 'restricted', - 'label' => trans('entities.permissions_enable'), - ]) + <div class="grid half left-focus v-center"> + <div> + <p class="mb-none mt-m">{{ trans('entities.permissions_intro') }}</p> + <div> + @include('form.checkbox', [ + 'name' => 'restricted', + 'label' => trans('entities.permissions_enable'), + ]) + </div> + </div> + <div> + <div class="form-group"> + <label for="owner">{{ trans('entities.permissions_owner') }}</label> + @include('components.user-select', ['user' => $model->ownedBy, 'name' => 'owned_by']) + </div> + </div> </div> + <hr> + <table permissions-table class="table permissions-table toggle-switch-list" style="{{ !$model->restricted ? 'display: none' : '' }}"> <tr> <th>{{ trans('common.role') }}</th> diff --git a/resources/views/partials/breadcrumb-listing.blade.php b/resources/views/partials/breadcrumb-listing.blade.php index a1a33ae1c..2a559aa7d 100644 --- a/resources/views/partials/breadcrumb-listing.blade.php +++ b/resources/views/partials/breadcrumb-listing.blade.php @@ -1,14 +1,23 @@ -<div class="breadcrumb-listing" component="dropdown" breadcrumb-listing="{{ $entity->getType() }}:{{ $entity->id }}"> - <div class="breadcrumb-listing-toggle" refs="dropdown@toggle" +<div class="dropdown-search" components="dropdown dropdown-search" + option:dropdown-search:url="/search/entity/siblings?entity_type={{$entity->getType()}}&entity_id={{ $entity->id }}" + option:dropdown-search:local-search-selector=".entity-list-item" +> + <div class="dropdown-search-toggle" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" tabindex="0"> <div class="separator">@icon('chevron-right')</div> </div> - <div refs="dropdown@menu" class="breadcrumb-listing-dropdown card" role="menu"> - <div class="breadcrumb-listing-search"> + <div refs="dropdown@menu" class="dropdown-search-dropdown card" role="menu"> + <div class="dropdown-search-search"> @icon('search') - <input autocomplete="off" type="text" name="entity-search" placeholder="{{ trans('common.search') }}" aria-label="{{ trans('common.search') }}"> + <input refs="dropdown-search@searchInput" + aria-label="{{ trans('common.search') }}" + autocomplete="off" + placeholder="{{ trans('common.search') }}" + type="text"> </div> - @include('partials.loading-icon') - <div class="breadcrumb-listing-entity-list px-m"></div> + <div refs="dropdown-search@loading"> + @include('partials.loading-icon') + </div> + <div refs="dropdown-search@listContainer" class="dropdown-search-list px-m"></div> </div> </div> \ No newline at end of file diff --git a/resources/views/partials/entity-meta.blade.php b/resources/views/partials/entity-meta.blade.php index f759ea25b..8996df9bb 100644 --- a/resources/views/partials/entity-meta.blade.php +++ b/resources/views/partials/entity-meta.blade.php @@ -1,34 +1,50 @@ <div class="entity-meta"> @if($entity->isA('revision')) - @icon('history'){{ trans('entities.pages_revision') }} - {{ trans('entities.pages_revisions_number') }}{{ $entity->revision_number == 0 ? '' : $entity->revision_number }} - <br> + <div> + @icon('history'){{ trans('entities.pages_revision') }} + {{ trans('entities.pages_revisions_number') }}{{ $entity->revision_number == 0 ? '' : $entity->revision_number }} + </div> @endif @if ($entity->isA('page')) - @if (userCan('page-update', $entity)) <a href="{{ $entity->getUrl('/revisions') }}"> @endif - @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} <br> + <div> + @if (userCan('page-update', $entity)) <a href="{{ $entity->getUrl('/revisions') }}"> @endif + @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} @if (userCan('page-update', $entity))</a>@endif + </div> @endif + @if ($entity->ownedBy && $entity->ownedBy->id !== $entity->createdBy->id) + <div> + @icon('user'){!! trans('entities.meta_owned_name', [ + 'user' => "<a href='{$entity->ownedBy->getProfileUrl()}'>".e($entity->ownedBy->name). "</a>" + ]) !!} + </div> + @endif @if ($entity->createdBy) - @icon('star'){!! trans('entities.meta_created_name', [ + <div> + @icon('star'){!! trans('entities.meta_created_name', [ 'timeLength' => '<span title="'.$entity->created_at->toDayDateTimeString().'">'.$entity->created_at->diffForHumans() . '</span>', - 'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".htmlentities($entity->createdBy->name). "</a>" + 'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".e($entity->createdBy->name). "</a>" ]) !!} + </div> @else - @icon('star')<span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span> + <div> + @icon('star')<span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span> + </div> @endif - <br> - @if ($entity->updatedBy) - @icon('edit'){!! trans('entities.meta_updated_name', [ + <div> + @icon('edit'){!! trans('entities.meta_updated_name', [ 'timeLength' => '<span title="' . $entity->updated_at->toDayDateTimeString() .'">' . $entity->updated_at->diffForHumans() .'</span>', - 'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".htmlentities($entity->updatedBy->name). "</a>" + 'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".e($entity->updatedBy->name). "</a>" ]) !!} + </div> @elseif (!$entity->isA('revision')) - @icon('edit')<span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span> + <div> + @icon('edit')<span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span> + </div> @endif </div> \ No newline at end of file diff --git a/resources/views/users/delete.blade.php b/resources/views/users/delete.blade.php index d3349c2f3..aba6f5cc1 100644 --- a/resources/views/users/delete.blade.php +++ b/resources/views/users/delete.blade.php @@ -12,6 +12,20 @@ <p>{{ trans('settings.users_delete_warning', ['userName' => $user->name]) }}</p> + <hr class="my-l"> + + <div class="grid half gap-xl v-center"> + <div> + <label class="setting-list-label">{{ trans('settings.users_migrate_ownership') }}</label> + <p class="small">{{ trans('settings.users_migrate_ownership_desc') }}</p> + </div> + <div> + @include('components.user-select', ['name' => 'new_owner_id', 'user' => null]) + </div> + </div> + + <hr class="my-l"> + <div class="grid half"> <p class="text-neg"><strong>{{ trans('settings.users_delete_confirm') }}</strong></p> <div> diff --git a/routes/web.php b/routes/web.php index afefcb99e..e8f217862 100644 --- a/routes/web.php +++ b/routes/web.php @@ -148,6 +148,9 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/search/chapter/{bookId}', 'SearchController@searchChapter'); Route::get('/search/entity/siblings', 'SearchController@searchSiblings'); + // User Search + Route::get('/search/users/select', 'UserSearchController@forSelect'); + Route::get('/templates', 'PageTemplateController@list'); Route::get('/templates/{templateId}', 'PageTemplateController@get'); diff --git a/tests/BrowserKitTest.php b/tests/BrowserKitTest.php index bb5aaa031..6c332a984 100644 --- a/tests/BrowserKitTest.php +++ b/tests/BrowserKitTest.php @@ -1,10 +1,16 @@ <?php namespace Tests; +use BookStack\Auth\User; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Auth\Role; use BookStack\Auth\Permissions\PermissionService; +use BookStack\Entities\Models\Page; use BookStack\Settings\SettingService; +use DB; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Foundation\Application; use Illuminate\Foundation\Testing\DatabaseTransactions; use Laravel\BrowserKitTesting\TestCase; use Symfony\Component\DomCrawler\Crawler; @@ -23,14 +29,14 @@ abstract class BrowserKitTest extends TestCase public function tearDown() : void { - \DB::disconnect(); + DB::disconnect(); parent::tearDown(); } /** * Creates the application. * - * @return \Illuminate\Foundation\Application + * @return Application */ public function createApplication() { @@ -47,7 +53,7 @@ abstract class BrowserKitTest extends TestCase */ public function getNormalUser() { - return \BookStack\Auth\User::where('system_name', '=', null)->get()->last(); + return User::where('system_name', '=', null)->get()->last(); } /** @@ -64,23 +70,21 @@ abstract class BrowserKitTest extends TestCase /** * Create a group of entities that belong to a specific user. - * @param $creatorUser - * @param $updaterUser - * @return array */ - protected function createEntityChainBelongingToUser($creatorUser, $updaterUser = false) + protected function createEntityChainBelongingToUser(User $creatorUser, ?User $updaterUser = null): array { - if ($updaterUser === false) $updaterUser = $creatorUser; - $book = factory(\BookStack\Entities\Models\Book::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]); - $chapter = factory(\BookStack\Entities\Models\Chapter::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id]); - $page = factory(\BookStack\Entities\Models\Page::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id, 'chapter_id' => $chapter->id]); + if (empty($updaterUser)) { + $updaterUser = $creatorUser; + } + + $userAttrs = ['created_by' => $creatorUser->id, 'owned_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]; + $book = factory(Book::class)->create($userAttrs); + $chapter = factory(Chapter::class)->create(array_merge(['book_id' => $book->id], $userAttrs)); + $page = factory(Page::class)->create(array_merge(['book_id' => $book->id, 'chapter_id' => $chapter->id], $userAttrs)); $restrictionService = $this->app[PermissionService::class]; $restrictionService->buildJointPermissionsForEntity($book); - return [ - 'book' => $book, - 'chapter' => $chapter, - 'page' => $page - ]; + + return compact('book', 'chapter', 'page'); } /** @@ -101,7 +105,7 @@ abstract class BrowserKitTest extends TestCase */ protected function getNewBlankUser($attributes = []) { - $user = factory(\BookStack\Auth\User::class)->create($attributes); + $user = factory(User::class)->create($attributes); return $user; } diff --git a/tests/Entity/SortTest.php b/tests/Entity/SortTest.php index bb67bfc3e..01f764b7b 100644 --- a/tests/Entity/SortTest.php +++ b/tests/Entity/SortTest.php @@ -287,7 +287,7 @@ class SortTest extends TestCase $resp = $this->actingAs($viewer)->get($page->getUrl()); $resp->assertDontSee($page->getUrl('/copy')); - $newBook->created_by = $viewer->id; + $newBook->owned_by = $viewer->id; $newBook->save(); $this->giveUserPermissions($viewer, ['page-create-own']); $this->regenEntityPermissions($newBook); diff --git a/tests/Permissions/EntityOwnerChangeTest.php b/tests/Permissions/EntityOwnerChangeTest.php new file mode 100644 index 000000000..2f06bff2e --- /dev/null +++ b/tests/Permissions/EntityOwnerChangeTest.php @@ -0,0 +1,50 @@ +<?php namespace Tests\Permissions; + +use BookStack\Auth\User; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Bookshelf; +use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Page; +use Illuminate\Support\Str; +use Tests\TestCase; + +class EntityOwnerChangeTest extends TestCase +{ + + public function test_changing_page_owner() + { + $page = Page::query()->first(); + $user = User::query()->where('id', '!=', $page->owned_by)->first(); + + $this->asAdmin()->put($page->getUrl('permissions'), ['owned_by' => $user->id]); + $this->assertDatabaseHas('pages', ['owned_by' => $user->id, 'id' => $page->id]); + } + + public function test_changing_chapter_owner() + { + $chapter = Chapter::query()->first(); + $user = User::query()->where('id', '!=', $chapter->owned_by)->first(); + + $this->asAdmin()->put($chapter->getUrl('permissions'), ['owned_by' => $user->id]); + $this->assertDatabaseHas('chapters', ['owned_by' => $user->id, 'id' => $chapter->id]); + } + + public function test_changing_book_owner() + { + $book = Book::query()->first(); + $user = User::query()->where('id', '!=', $book->owned_by)->first(); + + $this->asAdmin()->put($book->getUrl('permissions'), ['owned_by' => $user->id]); + $this->assertDatabaseHas('books', ['owned_by' => $user->id, 'id' => $book->id]); + } + + public function test_changing_shelf_owner() + { + $shelf = Bookshelf::query()->first(); + $user = User::query()->where('id', '!=', $shelf->owned_by)->first(); + + $this->asAdmin()->put($shelf->getUrl('permissions'), ['owned_by' => $user->id]); + $this->assertDatabaseHas('bookshelves', ['owned_by' => $user->id, 'id' => $shelf->id]); + } + +} \ No newline at end of file diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php index 9f32a9f49..3397ef429 100644 --- a/tests/Permissions/RolesTest.php +++ b/tests/Permissions/RolesTest.php @@ -289,7 +289,7 @@ class RolesTest extends BrowserKitTest { $otherShelf = Bookshelf::first(); $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']); - $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save(); + $ownShelf->forceFill(['owned_by' => $this->user->id, 'updated_by' => $this->user->id])->save(); $this->regenEntityPermissions($ownShelf); $this->checkAccessPermission('bookshelf-update-own', [ @@ -319,7 +319,7 @@ class RolesTest extends BrowserKitTest $this->giveUserPermissions($this->user, ['bookshelf-update-all']); $otherShelf = Bookshelf::first(); $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']); - $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save(); + $ownShelf->forceFill(['owned_by' => $this->user->id, 'updated_by' => $this->user->id])->save(); $this->regenEntityPermissions($ownShelf); $this->checkAccessPermission('bookshelf-delete-own', [ diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php new file mode 100644 index 000000000..d99d61401 --- /dev/null +++ b/tests/User/UserManagementTest.php @@ -0,0 +1,44 @@ +<?php namespace Tests\User; + +use BookStack\Actions\ActivityType; +use BookStack\Auth\User; +use BookStack\Entities\Models\Page; +use Tests\TestCase; + +class UserManagementTest extends TestCase +{ + + public function test_delete() + { + $editor = $this->getEditor(); + $resp = $this->asAdmin()->delete("settings/users/{$editor->id}"); + $resp->assertRedirect("/settings/users"); + $resp = $this->followRedirects($resp); + + $resp->assertSee("User successfully removed"); + $this->assertActivityExists(ActivityType::USER_DELETE); + + $this->assertDatabaseMissing('users', ['id' => $editor->id]); + } + + public function test_delete_offers_migrate_option() + { + $editor = $this->getEditor(); + $resp = $this->asAdmin()->get("settings/users/{$editor->id}/delete"); + $resp->assertSee("Migrate Ownership"); + $resp->assertSee("new_owner_id"); + } + + public function test_delete_with_new_owner_id_changes_ownership() + { + $page = Page::query()->first(); + $owner = $page->ownedBy; + $newOwner = User::query()->where('id', '!=' , $owner->id)->first(); + + $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id]); + $this->assertDatabaseHas('pages', [ + 'id' => $page->id, + 'owned_by' => $newOwner->id, + ]); + } +} \ No newline at end of file