mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-22 20:02:30 +00:00
Merge pull request #2436 from BookStackApp/ownership_system
Entity Ownership System
This commit is contained in:
commit
857d9ed3f1
47 changed files with 678 additions and 281 deletions
app
Actions
Auth
Entities
Http/Controllers
BookController.phpBookshelfController.phpChapterController.phpController.phpPageController.phpUserController.phpUserSearchController.php
Traits
Uploads
helpers.phpdatabase
resources
js/components
lang/en
sass
views
routes
tests
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
68
app/Entities/Tools/PermissionsUpdater.php
Normal file
68
app/Entities/Tools/PermissionsUpdater.php
Normal file
|
@ -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),
|
||||
] ;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
31
app/Http/Controllers/UserSearchController.php
Normal file
31
app/Http/Controllers/UserSearchController.php
Normal file
|
@ -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'));
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
19
app/Traits/HasOwner.php
Normal file
19
app/Traits/HasOwner.php
Normal file
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
|
@ -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'];
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 = [];
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
79
resources/js/components/dropdown-search.js
Normal file
79
resources/js/components/dropdown-search.js
Normal file
|
@ -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;
|
|
@ -17,6 +17,7 @@ class DropDown {
|
|||
this.body = document.body;
|
||||
this.showing = false;
|
||||
this.setupListeners();
|
||||
this.hide = this.hide.bind(this);
|
||||
}
|
||||
|
||||
show(event = null) {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
24
resources/js/components/user-select.js
Normal file
24
resources/js/components/user-select.js
Normal file
|
@ -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;
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -153,6 +153,9 @@ body.flexbox {
|
|||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
|
|
6
resources/views/components/user-select-list.blade.php
Normal file
6
resources/views/components/user-select-list.blade.php
Normal file
|
@ -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
|
34
resources/views/components/user-select.blade.php
Normal file
34
resources/views/components/user-select.blade.php
Normal file
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
50
tests/Permissions/EntityOwnerChangeTest.php
Normal file
50
tests/Permissions/EntityOwnerChangeTest.php
Normal file
|
@ -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]);
|
||||
}
|
||||
|
||||
}
|
|
@ -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', [
|
||||
|
|
44
tests/User/UserManagementTest.php
Normal file
44
tests/User/UserManagementTest.php
Normal file
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue