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 @@ 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 @@ 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 */ 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 @@ +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 @@ +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 @@ -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 @@ +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 @@ 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 @@ +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) + + {{ $user->name }} + {{ $user->name }} + +@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 @@ + \ 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() !!} -

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

- -
- @include('form.checkbox', [ - 'name' => 'restricted', - 'label' => trans('entities.permissions_enable'), - ]) +
+
+

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

+
+ @include('form.checkbox', [ + 'name' => 'restricted', + 'label' => trans('entities.permissions_enable'), + ]) +
+
+
+
+ + @include('components.user-select', ['user' => $model->ownedBy, 'name' => 'owned_by']) +
+
+
+ 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 @@ -
{{ trans('common.role') }}