diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index 08d6608a9..af4bbd8e3 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -18,7 +18,7 @@ class Chapter extends BookChild public $searchFactor = 1.2; - protected $fillable = ['name', 'description', 'priority', 'book_id']; + protected $fillable = ['name', 'description', 'priority']; protected $hidden = ['restricted', 'pivot', 'deleted_at']; /** diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index b10fc4530..87f9e9e40 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -5,6 +5,7 @@ namespace BookStack\Entities\Repos; use BookStack\Actions\ActivityType; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Entity; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; @@ -87,17 +88,9 @@ class ChapterRepo */ public function move(Chapter $chapter, string $parentIdentifier): Book { - $stringExploded = explode(':', $parentIdentifier); - $entityType = $stringExploded[0]; - $entityId = intval($stringExploded[1]); - - if ($entityType !== 'book') { - throw new MoveOperationException('Chapters can only be moved into books'); - } - /** @var Book $parent */ - $parent = Book::visible()->where('id', '=', $entityId)->first(); - if ($parent === null) { + $parent = $this->findParentByIdentifier($parentIdentifier); + if (is_null($parent)) { throw new MoveOperationException('Book to move chapter into not found'); } @@ -107,4 +100,24 @@ class ChapterRepo return $parent; } + + /** + * Find a page parent entity via an identifier string in the format: + * {type}:{id} + * Example: (book:5). + * + * @throws MoveOperationException + */ + public function findParentByIdentifier(string $identifier): ?Book + { + $stringExploded = explode(':', $identifier); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); + + if ($entityType !== 'book') { + throw new MoveOperationException('Chapters can only be in books'); + } + + return Book::visible()->where('id', '=', $entityId)->first(); + } } diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index b315bead9..992946461 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -347,50 +347,13 @@ class PageRepo } /** - * Copy an existing page in the system. - * Optionally providing a new parent via string identifier and a new name. - * - * @throws MoveOperationException - * @throws PermissionsException - */ - public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page - { - $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent(); - if ($parent === null) { - throw new MoveOperationException('Book or chapter to move page into not found'); - } - - if (!userCan('page-create', $parent)) { - throw new PermissionsException('User does not have permission to create a page within the new parent'); - } - - $copyPage = $this->getNewDraftPage($parent); - $pageData = $page->getAttributes(); - - // Update name - if (!empty($newName)) { - $pageData['name'] = $newName; - } - - // Copy tags from previous page if set - if ($page->tags) { - $pageData['tags'] = []; - foreach ($page->tags as $tag) { - $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value]; - } - } - - return $this->publishDraft($copyPage, $pageData); - } - - /** - * Find a page parent entity via a identifier string in the format: + * Find a page parent entity via an identifier string in the format: * {type}:{id} * Example: (book:5). * * @throws MoveOperationException */ - protected function findParentByIdentifier(string $identifier): ?Entity + public function findParentByIdentifier(string $identifier): ?Entity { $stringExploded = explode(':', $identifier); $entityType = $stringExploded[0]; diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php new file mode 100644 index 000000000..59d8077a4 --- /dev/null +++ b/app/Entities/Tools/Cloner.php @@ -0,0 +1,150 @@ +<?php + +namespace BookStack\Entities\Tools; + +use BookStack\Actions\Tag; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; +use BookStack\Entities\Repos\BookRepo; +use BookStack\Entities\Repos\ChapterRepo; +use BookStack\Entities\Repos\PageRepo; +use BookStack\Uploads\Image; +use BookStack\Uploads\ImageService; +use Illuminate\Http\UploadedFile; + +class Cloner +{ + + /** + * @var PageRepo + */ + protected $pageRepo; + + /** + * @var ChapterRepo + */ + protected $chapterRepo; + + /** + * @var BookRepo + */ + protected $bookRepo; + + /** + * @var ImageService + */ + protected $imageService; + + public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService) + { + $this->pageRepo = $pageRepo; + $this->chapterRepo = $chapterRepo; + $this->bookRepo = $bookRepo; + $this->imageService = $imageService; + } + + /** + * Clone the given page into the given parent using the provided name. + */ + public function clonePage(Page $original, Entity $parent, string $newName): Page + { + $copyPage = $this->pageRepo->getNewDraftPage($parent); + $pageData = $original->getAttributes(); + + // Update name & tags + $pageData['name'] = $newName; + $pageData['tags'] = $this->entityTagsToInputArray($original); + + return $this->pageRepo->publishDraft($copyPage, $pageData); + } + + /** + * Clone the given page into the given parent using the provided name. + * Clones all child pages. + */ + public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter + { + $chapterDetails = $original->getAttributes(); + $chapterDetails['name'] = $newName; + $chapterDetails['tags'] = $this->entityTagsToInputArray($original); + + $copyChapter = $this->chapterRepo->create($chapterDetails, $parent); + + if (userCan('page-create', $copyChapter)) { + /** @var Page $page */ + foreach ($original->getVisiblePages() as $page) { + $this->clonePage($page, $copyChapter, $page->name); + } + } + + return $copyChapter; + } + + /** + * Clone the given book. + * Clones all child chapters & pages. + */ + public function cloneBook(Book $original, string $newName): Book + { + $bookDetails = $original->getAttributes(); + $bookDetails['name'] = $newName; + $bookDetails['tags'] = $this->entityTagsToInputArray($original); + + $copyBook = $this->bookRepo->create($bookDetails); + + $directChildren = $original->getDirectChildren(); + foreach ($directChildren as $child) { + + if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) { + $this->cloneChapter($child, $copyBook, $child->name); + } + + if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) { + $this->clonePage($child, $copyBook, $child->name); + } + } + + if ($original->cover) { + try { + $tmpImgFile = tmpfile(); + $uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile); + $this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false); + } catch (\Exception $exception) { + } + } + + return $copyBook; + } + + /** + * Convert an image instance to an UploadedFile instance to mimic + * a file being uploaded. + */ + protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile + { + $imgData = $this->imageService->getImageData($image); + $tmpImgFilePath = stream_get_meta_data($tmpFile)['uri']; + file_put_contents($tmpImgFilePath, $imgData); + + return new UploadedFile($tmpImgFilePath, basename($image->path)); + } + + /** + * Convert the tags on the given entity to the raw format + * that's used for incoming request data. + */ + protected function entityTagsToInputArray(Entity $entity): array + { + $tags = []; + + /** @var Tag $tag */ + foreach ($entity->tags as $tag) { + $tags[] = ['name' => $tag->name, 'value' => $tag->value]; + } + + return $tags; + } + +} \ No newline at end of file diff --git a/app/Facades/Activity.php b/app/Facades/Activity.php index 76493efd7..6c279a057 100644 --- a/app/Facades/Activity.php +++ b/app/Facades/Activity.php @@ -4,6 +4,9 @@ namespace BookStack\Facades; use Illuminate\Support\Facades\Facade; +/** + * @see \BookStack\Actions\ActivityLogger + */ class Activity extends Facade { /** diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index 5434afaf8..bc403c6d0 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -2,16 +2,18 @@ namespace BookStack\Http\Controllers; -use Activity; use BookStack\Actions\ActivityQueries; use BookStack\Actions\ActivityType; use BookStack\Actions\View; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Tools\BookContents; +use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Entities\Tools\ShelfContext; use BookStack\Exceptions\ImageUploadException; +use BookStack\Exceptions\NotFoundException; +use BookStack\Facades\Activity; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Throwable; @@ -225,4 +227,39 @@ class BookController extends Controller return redirect($book->getUrl()); } + + /** + * Show the view to copy a book. + * + * @throws NotFoundException + */ + public function showCopy(string $bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-view', $book); + + session()->flashInput(['name' => $book->name]); + + return view('books.copy', [ + 'book' => $book, + ]); + } + + /** + * Create a copy of a book within the requested target destination. + * + * @throws NotFoundException + */ + public function copy(Request $request, Cloner $cloner, string $bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-view', $book); + $this->checkPermission('book-create-all'); + + $newName = $request->get('name') ?: $book->name; + $bookCopy = $cloner->cloneBook($book, $newName); + $this->showSuccessNotification(trans('entities.books_copy_success')); + + return redirect($bookCopy->getUrl()); + } } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 9d2bd2489..16f0779ca 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -6,6 +6,7 @@ use BookStack\Actions\View; use BookStack\Entities\Models\Book; use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Tools\BookContents; +use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\NextPreviousContentLocator; use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Exceptions\MoveOperationException; @@ -190,6 +191,52 @@ class ChapterController extends Controller return redirect($chapter->getUrl()); } + /** + * Show the view to copy a chapter. + * + * @throws NotFoundException + */ + public function showCopy(string $bookSlug, string $chapterSlug) + { + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); + $this->checkOwnablePermission('chapter-view', $chapter); + + session()->flashInput(['name' => $chapter->name]); + + return view('chapters.copy', [ + 'book' => $chapter->book, + 'chapter' => $chapter, + ]); + } + + /** + * Create a copy of a chapter within the requested target destination. + * + * @throws NotFoundException + * @throws Throwable + */ + public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug) + { + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); + $this->checkOwnablePermission('chapter-view', $chapter); + + $entitySelection = $request->get('entity_selection') ?: null; + $newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent(); + + if (is_null($newParentBook)) { + $this->showErrorNotification(trans('errors.selected_book_not_found')); + return redirect()->back(); + } + + $this->checkOwnablePermission('chapter-create', $newParentBook); + + $newName = $request->get('name') ?: $chapter->name; + $chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName); + $this->showSuccessNotification(trans('entities.chapters_copy_success')); + + return redirect($chapterCopy->getUrl()); + } + /** * Show the Restrictions view. * diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 7d5d93ffa..4a01dcc62 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -6,6 +6,7 @@ use BookStack\Actions\View; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Tools\BookContents; +use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\NextPreviousContentLocator; use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageEditActivity; @@ -447,26 +448,23 @@ class PageController extends Controller * @throws NotFoundException * @throws Throwable */ - public function copy(Request $request, string $bookSlug, string $pageSlug) + public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug) { $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-view', $page); - $entitySelection = $request->get('entity_selection', null) ?? null; - $newName = $request->get('name', null); - - try { - $pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName); - } catch (Exception $exception) { - if ($exception instanceof PermissionsException) { - $this->showPermissionError(); - } + $entitySelection = $request->get('entity_selection') ?: null; + $newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent(); + if (is_null($newParent)) { $this->showErrorNotification(trans('errors.selected_book_chapter_not_found')); - return redirect()->back(); } + $this->checkOwnablePermission('page-create', $newParent); + + $newName = $request->get('name') ?: $page->name; + $pageCopy = $cloner->clonePage($page, $newParent, $newName); $this->showSuccessNotification(trans('entities.pages_copy_success')); return redirect($pageCopy->getUrl()); diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php index 25b2a5851..7c72b5970 100644 --- a/app/Http/Controllers/RoleController.php +++ b/app/Http/Controllers/RoleController.php @@ -3,6 +3,7 @@ namespace BookStack\Http\Controllers; use BookStack\Auth\Permissions\PermissionsRepo; +use BookStack\Auth\Role; use BookStack\Exceptions\PermissionsException; use Exception; use Illuminate\Http\Request; @@ -34,11 +35,21 @@ class RoleController extends Controller /** * Show the form to create a new role. */ - public function create() + public function create(Request $request) { $this->checkPermission('user-roles-manage'); - return view('settings.roles.create'); + /** @var ?Role $role */ + $role = null; + if ($request->has('copy_from')) { + $role = Role::query()->find($request->get('copy_from')); + } + + if ($role) { + $role->display_name .= ' (' . trans('common.copy') . ')'; + } + + return view('settings.roles.create', ['role' => $role]); } /** @@ -49,7 +60,7 @@ class RoleController extends Controller $this->checkPermission('user-roles-manage'); $this->validate($request, [ 'display_name' => ['required', 'min:3', 'max:180'], - 'description' => 'max:180', + 'description' => ['max:180'], ]); $this->permissionsRepo->saveNewRole($request->all()); @@ -84,7 +95,7 @@ class RoleController extends Controller $this->checkPermission('user-roles-manage'); $this->validate($request, [ 'display_name' => ['required', 'min:3', 'max:180'], - 'description' => 'max:180', + 'description' => ['max:180'], ]); $this->permissionsRepo->updateRole($id, $request->all()); diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 5cf47629a..4e4bbccd3 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -143,6 +143,8 @@ return [ 'books_sort_chapters_last' => 'Chapters Last', 'books_sort_show_other' => 'Show Other Books', 'books_sort_save' => 'Save New Order', + 'books_copy' => 'Copy Book', + 'books_copy_success' => 'Book successfully copied', // Chapters 'chapter' => 'Chapter', @@ -161,6 +163,8 @@ return [ 'chapters_move' => 'Move Chapter', 'chapters_move_named' => 'Move Chapter :chapterName', 'chapter_move_success' => 'Chapter moved to :bookName', + 'chapters_copy' => 'Copy Chapter', + 'chapters_copy_success' => 'Chapter successfully copied', 'chapters_permissions' => 'Chapter Permissions', 'chapters_empty' => 'No pages are currently in this chapter.', 'chapters_permissions_active' => 'Chapter Permissions Active', @@ -332,4 +336,12 @@ return [ 'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.', 'revision_delete_success' => 'Revision deleted', 'revision_cannot_delete_latest' => 'Cannot delete the latest revision.', + + // Copy view + 'copy_consider' => 'Please consider the below when copying content.', + 'copy_consider_permissions' => 'Custom permission settings will not be copied.', + 'copy_consider_owner' => 'You will become the owner of all copied content.', + 'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.', + 'copy_consider_attachments' => 'Page attachments will not be copied.', + 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', ]; diff --git a/resources/views/books/copy.blade.php b/resources/views/books/copy.blade.php new file mode 100644 index 000000000..293397a97 --- /dev/null +++ b/resources/views/books/copy.blade.php @@ -0,0 +1,40 @@ +@extends('layouts.simple') + +@section('body') + + <div class="container small"> + + <div class="my-s"> + @include('entities.breadcrumbs', ['crumbs' => [ + $book, + $book->getUrl('/copy') => [ + 'text' => trans('entities.books_copy'), + 'icon' => 'copy', + ] + ]]) + </div> + + <div class="card content-wrap auto-height"> + + <h1 class="list-heading">{{ trans('entities.books_copy') }}</h1> + + <form action="{{ $book->getUrl('/copy') }}" method="POST"> + {!! csrf_field() !!} + + <div class="form-group title-input"> + <label for="name">{{ trans('common.name') }}</label> + @include('form.text', ['name' => 'name']) + </div> + + @include('entities.copy-considerations') + + <div class="form-group text-right"> + <a href="{{ $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a> + <button type="submit" class="button">{{ trans('entities.books_copy') }}</button> + </div> + </form> + + </div> + </div> + +@stop diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index 25a6f69fa..5263bc810 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -110,6 +110,12 @@ <span>{{ trans('common.sort') }}</span> </a> @endif + @if(userCan('book-create-all')) + <a href="{{ $book->getUrl('/copy') }}" class="icon-list-item"> + <span>@icon('copy')</span> + <span>{{ trans('common.copy') }}</span> + </a> + @endif @if(userCan('restrictions-manage', $book)) <a href="{{ $book->getUrl('/permissions') }}" class="icon-list-item"> <span>@icon('lock')</span> diff --git a/resources/views/chapters/copy.blade.php b/resources/views/chapters/copy.blade.php new file mode 100644 index 000000000..3fd5de1ff --- /dev/null +++ b/resources/views/chapters/copy.blade.php @@ -0,0 +1,50 @@ +@extends('layouts.simple') + +@section('body') + + <div class="container small"> + + <div class="my-s"> + @include('entities.breadcrumbs', ['crumbs' => [ + $chapter->book, + $chapter, + $chapter->getUrl('/copy') => [ + 'text' => trans('entities.chapters_copy'), + 'icon' => 'copy', + ] + ]]) + </div> + + <div class="card content-wrap auto-height"> + + <h1 class="list-heading">{{ trans('entities.chapters_copy') }}</h1> + + <form action="{{ $chapter->getUrl('/copy') }}" method="POST"> + {!! csrf_field() !!} + + <div class="form-group title-input"> + <label for="name">{{ trans('common.name') }}</label> + @include('form.text', ['name' => 'name']) + </div> + + <div class="form-group" collapsible> + <button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false"> + <label for="entity_selection">{{ trans('entities.pages_copy_desination') }}</label> + </button> + <div class="collapse-content" collapsible-content> + @include('entities.selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book', 'entityPermission' => 'chapter-create']) + </div> + </div> + + @include('entities.copy-considerations') + + <div class="form-group text-right"> + <a href="{{ $chapter->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a> + <button type="submit" class="button">{{ trans('entities.chapters_copy') }}</button> + </div> + </form> + + </div> + </div> + +@stop diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index 1646d4f18..edd39edde 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -108,6 +108,12 @@ <span>{{ trans('common.edit') }}</span> </a> @endif + @if(userCanOnAny('chapter-create')) + <a href="{{ $chapter->getUrl('/copy') }}" class="icon-list-item"> + <span>@icon('copy')</span> + <span>{{ trans('common.copy') }}</span> + </a> + @endif @if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter)) <a href="{{ $chapter->getUrl('/move') }}" class="icon-list-item"> <span>@icon('folder')</span> diff --git a/resources/views/entities/copy-considerations.blade.php b/resources/views/entities/copy-considerations.blade.php new file mode 100644 index 000000000..6fe50ef98 --- /dev/null +++ b/resources/views/entities/copy-considerations.blade.php @@ -0,0 +1,15 @@ +<p class="text-warn mb-none mt-l"> + @icon('warning') <strong>{{ trans('entities.copy_consider') }}</strong> +</p> + +<div class="grid half no-gap no-row-gap text-warn mb-m"> + <ul class="pr-s mb-none"> + <li>{{ trans('entities.copy_consider_permissions') }}</li> + <li>{{ trans('entities.copy_consider_owner') }}</li> + <li>{{ trans('entities.copy_consider_images') }}</li> + </ul> + <ul class="pr-s mb-none"> + <li>{{ trans('entities.copy_consider_attachments') }}</li> + <li>{{ trans('entities.copy_consider_access') }}</li> + </ul> +</div> \ No newline at end of file diff --git a/resources/views/pages/copy.blade.php b/resources/views/pages/copy.blade.php index 2f24d8165..9f249863a 100644 --- a/resources/views/pages/copy.blade.php +++ b/resources/views/pages/copy.blade.php @@ -37,6 +37,8 @@ </div> </div> + @include('entities.copy-considerations') + <div class="form-group text-right"> <a href="{{ $page->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a> <button type="submit" class="button">{{ trans('entities.pages_copy') }}</button> diff --git a/resources/views/settings/roles/create.blade.php b/resources/views/settings/roles/create.blade.php index f2edfa1c5..72afc60a8 100644 --- a/resources/views/settings/roles/create.blade.php +++ b/resources/views/settings/roles/create.blade.php @@ -8,9 +8,21 @@ @include('settings.parts.navbar', ['selected' => 'roles']) </div> - <form action="{{ url("/settings/roles/new") }}" method="POST"> - @include('settings.roles.parts.form', ['title' => trans('settings.role_create')]) - </form> + <div class="card content-wrap"> + <h1 class="list-heading">{{ trans('settings.role_create') }}</h1> + + <form action="{{ url("/settings/roles/new") }}" method="POST"> + {{ csrf_field() }} + + @include('settings.roles.parts.form', ['role' => $role ?? null]) + + <div class="form-group text-right"> + <a href="{{ url("/settings/roles") }}" class="button outline">{{ trans('common.cancel') }}</a> + <button type="submit" class="button">{{ trans('settings.role_save') }}</button> + </div> + </form> + + </div> </div> @stop diff --git a/resources/views/settings/roles/edit.blade.php b/resources/views/settings/roles/edit.blade.php index e2018d3e9..dda8db39d 100644 --- a/resources/views/settings/roles/edit.blade.php +++ b/resources/views/settings/roles/edit.blade.php @@ -7,10 +7,53 @@ @include('settings.parts.navbar', ['selected' => 'roles']) </div> - <form action="{{ url("/settings/roles/{$role->id}") }}" method="POST"> - <input type="hidden" name="_method" value="PUT"> - @include('settings.roles.parts.form', ['model' => $role, 'title' => trans('settings.role_edit'), 'icon' => 'edit']) - </form> + <div class="card content-wrap"> + <h1 class="list-heading">{{ trans('settings.role_edit') }}</h1> + + <form action="{{ url("/settings/roles/{$role->id}") }}" method="POST"> + {{ csrf_field() }} + {{ method_field('PUT') }} + + @include('settings.roles.parts.form', ['role' => $role]) + + <div class="form-group text-right"> + <a href="{{ url("/settings/roles") }}" class="button outline">{{ trans('common.cancel') }}</a> + <a href="{{ url("/settings/roles/new?copy_from={$role->id}") }}" class="button outline">{{ trans('common.copy') }}</a> + <a href="{{ url("/settings/roles/delete/{$role->id}") }}" class="button outline">{{ trans('settings.role_delete') }}</a> + <button type="submit" class="button">{{ trans('settings.role_save') }}</button> + </div> + </form> + + </div> + + + <div class="card content-wrap auto-height"> + <h2 class="list-heading">{{ trans('settings.role_users') }}</h2> + @if(count($role->users ?? []) > 0) + <div class="grid third"> + @foreach($role->users as $user) + <div class="user-list-item"> + <div> + <img class="avatar small" src="{{ $user->getAvatar(40) }}" alt="{{ $user->name }}"> + </div> + <div> + @if(userCan('users-manage') || user()->id == $user->id) + <a href="{{ url("/settings/users/{$user->id}") }}"> + @endif + {{ $user->name }} + @if(userCan('users-manage') || user()->id == $user->id) + </a> + @endif + </div> + </div> + @endforeach + </div> + @else + <p class="text-muted"> + {{ trans('settings.role_users_none') }} + </p> + @endif + </div> </div> @stop diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 9cea9e1fb..a15117e5e 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -1,267 +1,224 @@ -{!! csrf_field() !!} - -<div class="card content-wrap"> - <h1 class="list-heading">{{ $title }}</h1> - - <div class="setting-list"> - - <div class="grid half"> - <div> - <label class="setting-list-label">{{ trans('settings.role_details') }}</label> - </div> - <div> - <div class="form-group"> - <label for="display_name">{{ trans('settings.role_name') }}</label> - @include('form.text', ['name' => 'display_name']) - </div> - <div class="form-group"> - <label for="description">{{ trans('settings.role_desc') }}</label> - @include('form.text', ['name' => 'description']) - </div> - <div class="form-group"> - @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced') ]) - </div> - - @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc'])) - <div class="form-group"> - <label for="name">{{ trans('settings.role_external_auth_id') }}</label> - @include('form.text', ['name' => 'external_auth_id']) - </div> - @endif - </div> - </div> - - <div permissions-table> - <label class="setting-list-label">{{ trans('settings.role_system') }}</label> - <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a> - - <div class="toggle-switch-list grid half mt-m"> - <div> - <div>@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div> - <div>@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div> - <div>@include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div> - <div>@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div> - <div>@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])</div> - </div> - <div> - <div>@include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div> - <div>@include('settings.roles.parts.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])</div> - <div>@include('settings.roles.parts.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div> - <p class="text-warn text-small mt-s mb-none">{{ trans('settings.roles_system_warning') }}</p> - </div> - </div> - </div> +<div class="setting-list"> + <div class="grid half"> <div> - <label class="setting-list-label">{{ trans('settings.role_asset') }}</label> - <p>{{ trans('settings.role_asset_desc') }}</p> - - @if (isset($role) && $role->system_name === 'admin') - <p class="text-warn">{{ trans('settings.role_asset_admins') }}</p> - @endif - - <table permissions-table class="table toggle-switch-list compact permissions-table"> - <tr> - <th width="20%"> - <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a> - </th> - <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.create') }}</th> - <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.view') }}</th> - <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.edit') }}</th> - <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.delete') }}</th> - </tr> - <tr> - <td> - <div>{{ trans('entities.shelves_long') }}</div> - <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-create-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-all', 'label' => trans('settings.role_all')]) - </td> - </tr> - <tr> - <td> - <div>{{ trans('entities.books') }}</div> - <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'book-create-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'book-view-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'book-view-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'book-update-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'book-update-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-all', 'label' => trans('settings.role_all')]) - </td> - </tr> - <tr> - <td> - <div>{{ trans('entities.chapters') }}</div> - <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-all', 'label' => trans('settings.role_all')]) - </td> - </tr> - <tr> - <td> - <div>{{ trans('entities.pages') }}</div> - <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'page-create-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'page-create-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'page-view-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'page-view-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'page-update-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'page-update-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-all', 'label' => trans('settings.role_all')]) - </td> - </tr> - <tr> - <td> - <div>{{ trans('entities.images') }}</div> - <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> - </td> - <td>@include('settings.roles.parts.checkbox', ['permission' => 'image-create-all', 'label' => ''])</td> - <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'image-update-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'image-update-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-all', 'label' => trans('settings.role_all')]) - </td> - </tr> - <tr> - <td> - <div>{{ trans('entities.attachments') }}</div> - <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> - </td> - <td>@include('settings.roles.parts.checkbox', ['permission' => 'attachment-create-all', 'label' => ''])</td> - <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-all', 'label' => trans('settings.role_all')]) - </td> - </tr> - <tr> - <td> - <div>{{ trans('entities.comments') }}</div> - <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> - </td> - <td>@include('settings.roles.parts.checkbox', ['permission' => 'comment-create-all', 'label' => ''])</td> - <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-all', 'label' => trans('settings.role_all')]) - </td> - <td> - @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-own', 'label' => trans('settings.role_own')]) - <br> - @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-all', 'label' => trans('settings.role_all')]) - </td> - </tr> - </table> + <label class="setting-list-label">{{ trans('settings.role_details') }}</label> </div> - </div> + <div> + <div class="form-group"> + <label for="display_name">{{ trans('settings.role_name') }}</label> + @include('form.text', ['name' => 'display_name', 'model' => $role]) + </div> + <div class="form-group"> + <label for="description">{{ trans('settings.role_desc') }}</label> + @include('form.text', ['name' => 'description', 'model' => $role]) + </div> + <div class="form-group"> + @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced'), 'model' => $role ]) + </div> - <div class="form-group text-right"> - <a href="{{ url("/settings/roles") }}" class="button outline">{{ trans('common.cancel') }}</a> - @if (isset($role) && $role->id) - <a href="{{ url("/settings/roles/delete/{$role->id}") }}" class="button outline">{{ trans('settings.role_delete') }}</a> - @endif - <button type="submit" class="button">{{ trans('settings.role_save') }}</button> - </div> - -</div> - -<div class="card content-wrap auto-height"> - <h2 class="list-heading">{{ trans('settings.role_users') }}</h2> - @if(count($role->users ?? []) > 0) - <div class="grid third"> - @foreach($role->users as $user) - <div class="user-list-item"> - <div> - <img class="avatar small" src="{{ $user->getAvatar(40) }}" alt="{{ $user->name }}"> - </div> - <div> - @if(userCan('users-manage') || user()->id == $user->id) - <a href="{{ url("/settings/users/{$user->id}") }}"> - @endif - {{ $user->name }} - @if(userCan('users-manage') || user()->id == $user->id) - </a> - @endif - </div> + @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc'])) + <div class="form-group"> + <label for="name">{{ trans('settings.role_external_auth_id') }}</label> + @include('form.text', ['name' => 'external_auth_id', 'model' => $role]) </div> - @endforeach + @endif </div> - @else - <p class="text-muted"> - {{ trans('settings.role_users_none') }} - </p> - @endif -</div> + </div> + + <div permissions-table> + <label class="setting-list-label">{{ trans('settings.role_system') }}</label> + <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a> + + <div class="toggle-switch-list grid half mt-m"> + <div> + <div>@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div> + <div>@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div> + <div>@include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div> + <div>@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div> + <div>@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])</div> + </div> + <div> + <div>@include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div> + <div>@include('settings.roles.parts.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])</div> + <div>@include('settings.roles.parts.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div> + <p class="text-warn text-small mt-s mb-none">{{ trans('settings.roles_system_warning') }}</p> + </div> + </div> + </div> + + <div> + <label class="setting-list-label">{{ trans('settings.role_asset') }}</label> + <p>{{ trans('settings.role_asset_desc') }}</p> + + @if (isset($role) && $role->system_name === 'admin') + <p class="text-warn">{{ trans('settings.role_asset_admins') }}</p> + @endif + + <table permissions-table class="table toggle-switch-list compact permissions-table"> + <tr> + <th width="20%"> + <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a> + </th> + <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.create') }}</th> + <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.view') }}</th> + <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.edit') }}</th> + <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.delete') }}</th> + </tr> + <tr> + <td> + <div>{{ trans('entities.shelves_long') }}</div> + <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-create-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-all', 'label' => trans('settings.role_all')]) + </td> + </tr> + <tr> + <td> + <div>{{ trans('entities.books') }}</div> + <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'book-create-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'book-view-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'book-view-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'book-update-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'book-update-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-all', 'label' => trans('settings.role_all')]) + </td> + </tr> + <tr> + <td> + <div>{{ trans('entities.chapters') }}</div> + <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-all', 'label' => trans('settings.role_all')]) + </td> + </tr> + <tr> + <td> + <div>{{ trans('entities.pages') }}</div> + <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'page-create-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'page-create-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'page-view-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'page-view-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'page-update-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'page-update-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-all', 'label' => trans('settings.role_all')]) + </td> + </tr> + <tr> + <td> + <div>{{ trans('entities.images') }}</div> + <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> + </td> + <td>@include('settings.roles.parts.checkbox', ['permission' => 'image-create-all', 'label' => ''])</td> + <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'image-update-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'image-update-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-all', 'label' => trans('settings.role_all')]) + </td> + </tr> + <tr> + <td> + <div>{{ trans('entities.attachments') }}</div> + <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> + </td> + <td>@include('settings.roles.parts.checkbox', ['permission' => 'attachment-create-all', 'label' => ''])</td> + <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-all', 'label' => trans('settings.role_all')]) + </td> + </tr> + <tr> + <td> + <div>{{ trans('entities.comments') }}</div> + <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a> + </td> + <td>@include('settings.roles.parts.checkbox', ['permission' => 'comment-create-all', 'label' => ''])</td> + <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-all', 'label' => trans('settings.role_all')]) + </td> + <td> + @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-own', 'label' => trans('settings.role_own')]) + <br> + @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-all', 'label' => trans('settings.role_all')]) + </td> + </tr> + </table> + </div> +</div> \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index d7e734c33..73cc3dc66 100644 --- a/routes/web.php +++ b/routes/web.php @@ -80,6 +80,8 @@ Route::middleware('auth')->group(function () { Route::get('/books/{bookSlug}/permissions', [BookController::class, 'showPermissions']); Route::put('/books/{bookSlug}/permissions', [BookController::class, 'permissions']); Route::get('/books/{slug}/delete', [BookController::class, 'showDelete']); + Route::get('/books/{bookSlug}/copy', [BookController::class, 'showCopy']); + Route::post('/books/{bookSlug}/copy', [BookController::class, 'copy']); Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); Route::get('/books/{bookSlug}/export/html', [BookExportController::class, 'html']); @@ -127,6 +129,8 @@ Route::middleware('auth')->group(function () { Route::put('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'update']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'showMove']); Route::put('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'move']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'showCopy']); + Route::post('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'copy']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [ChapterController::class, 'edit']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'showPermissions']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ChapterExportController::class, 'pdf']); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 2894fbb98..7f102a17e 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -3,10 +3,15 @@ namespace Tests\Entity; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\BookChild; +use BookStack\Entities\Repos\BookRepo; use Tests\TestCase; +use Tests\Uploads\UsesImages; class BookTest extends TestCase { + use UsesImages; + public function test_create() { $book = Book::factory()->make([ @@ -204,4 +209,88 @@ class BookTest extends TestCase $this->assertEquals('parta-partb-partc', $book->slug); } + + public function test_show_view_has_copy_button() + { + /** @var Book $book */ + $book = Book::query()->first(); + $resp = $this->asEditor()->get($book->getUrl()); + + $resp->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_copy_view() + { + /** @var Book $book */ + $book = Book::query()->first(); + $resp = $this->asEditor()->get($book->getUrl('/copy')); + + $resp->assertOk(); + $resp->assertSee('Copy Book'); + $resp->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]"); + } + + public function test_copy() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $resp->assertRedirect($copy->getUrl()); + $this->assertEquals($book->getDirectChildren()->count(), $copy->getDirectChildren()->count()); + } + + public function test_copy_does_not_copy_non_visible_content() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + + // Hide child content + /** @var BookChild $page */ + foreach ($book->getDirectChildren() as $child) { + $child->restricted = true; + $child->save(); + $this->regenEntityPermissions($child); + } + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->getDirectChildren()->count()); + } + + public function test_copy_does_not_copy_pages_or_chapters_if_user_cant_create() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first(); + $viewer = $this->getViewer(); + $this->giveUserPermissions($viewer, ['book-create-all']); + + $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->pages()->count()); + $this->assertEquals(0, $copy->chapters()->count()); + } + + public function test_copy_clones_cover_image_if_existing() + { + /** @var Book $book */ + $book = Book::query()->first(); + $bookRepo = $this->app->make(BookRepo::class); + $coverImageFile = $this->getTestImage('cover.png'); + $bookRepo->updateCoverImage($book, $coverImageFile); + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + $this->assertNotNull($copy->cover); + $this->assertNotEquals($book->cover->id, $copy->cover->id); + } } diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php index 9868dc030..1d28ec839 100644 --- a/tests/Entity/ChapterTest.php +++ b/tests/Entity/ChapterTest.php @@ -4,6 +4,7 @@ namespace Tests\Entity; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Page; use Tests\TestCase; class ChapterTest extends TestCase @@ -54,4 +55,95 @@ class ChapterTest extends TestCase $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location')); $redirectReq->assertNotificationContains('Chapter Successfully Deleted'); } + + public function test_show_view_has_copy_button() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->first(); + + $resp = $this->asEditor()->get($chapter->getUrl()); + $resp->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_copy_view() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->first(); + + $resp = $this->asEditor()->get($chapter->getUrl('/copy')); + $resp->assertOk(); + $resp->assertSee('Copy Chapter'); + $resp->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]"); + $resp->assertElementExists("input[name=\"entity_selection\"]"); + } + + public function test_copy() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + /** @var Book $otherBook */ + $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); + + $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + 'entity_selection' => 'book:' . $otherBook->id, + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + + $resp->assertRedirect($newChapter->getUrl()); + $this->assertEquals($otherBook->id, $newChapter->book_id); + $this->assertEquals($chapter->pages->count(), $newChapter->pages->count()); + } + + public function test_copy_does_not_copy_non_visible_pages() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + + // Hide pages to all non-admin roles + /** @var Page $page */ + foreach ($chapter->pages as $page) { + $page->restricted = true; + $page->save(); + $this->regenEntityPermissions($page); + } + + $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + } + + public function test_copy_does_not_copy_pages_if_user_cant_page_create() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + $viewer = $this->getViewer(); + $this->giveUserPermissions($viewer, ['chapter-create-all']); + + // Lacking permission results in no copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + + $this->giveUserPermissions($viewer, ['page-create-all']); + + // Having permission rules in copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied again chapter', + ]); + + /** @var Chapter $newChapter2 */ + $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first(); + $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count()); + } } diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php index c880bdd00..9368c7163 100644 --- a/tests/Permissions/RolesTest.php +++ b/tests/Permissions/RolesTest.php @@ -163,6 +163,23 @@ class RolesTest extends TestCase $this->assertEquals($this->user->id, $roleA->users()->first()->id); } + public function test_copy_role_button_shown() + { + /** @var Role $role */ + $role = Role::query()->first(); + $resp = $this->asAdmin()->get("/settings/roles/{$role->id}"); + $resp->assertElementContains('a[href$="/roles/new?copy_from=' . $role->id . '"]', 'Copy'); + } + + public function test_copy_from_param_on_create_prefills_with_other_role_data() + { + /** @var Role $role */ + $role = Role::query()->first(); + $resp = $this->asAdmin()->get("/settings/roles/new?copy_from={$role->id}"); + $resp->assertOk(); + $resp->assertElementExists('input[name="display_name"][value="' . ($role->display_name . ' (Copy)') . '"]'); + } + public function test_manage_user_permission() { $this->actingAs($this->user)->get('/settings/users')->assertRedirect('/');