Merge pull request #2436 from BookStackApp/ownership_system

Entity Ownership System
This commit is contained in:
Dan Brown 2021-01-02 00:29:22 +00:00 committed by GitHub
commit 857d9ed3f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 678 additions and 281 deletions

View File

@ -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;
}

View File

@ -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);
});
});
});

View File

@ -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]);
}
}
/**

View File

@ -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
{

View File

@ -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

View File

@ -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);
}
}

View File

@ -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

View File

@ -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.
*/

View File

@ -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

View File

@ -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.
*/

View 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),
] ;
});
});
}
}

View File

@ -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());

View File

@ -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());

View File

@ -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());

View File

@ -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();

View File

@ -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());

View File

@ -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);

View 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'));
}
}

View File

@ -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
View 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');
}
}

View File

@ -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'];
/**

View File

@ -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 = [];

View File

@ -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);

View File

@ -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');
});
}
}

View File

@ -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) {

View File

@ -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;

View 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;

View File

@ -17,6 +17,7 @@ class DropDown {
this.body = document.body;
this.showing = false;
this.setupListeners();
this.hide = this.hide.bind(this);
}
show(event = null) {

View File

@ -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,
};

View 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;

View File

@ -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',

View File

@ -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',

View File

@ -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;
}

View File

@ -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;

View File

@ -153,6 +153,9 @@ body.flexbox {
.justify-center {
justify-content: center;
}
.items-center {
align-items: center;
}
/**

View 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

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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');

View File

@ -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;
}

View File

@ -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);

View 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]);
}
}

View File

@ -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', [

View 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,
]);
}
}