]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'japanese-translation' of git://github.com/S64/BookStack into S64-japane...
authorDan Brown <redacted>
Sun, 2 Jul 2017 15:09:12 +0000 (16:09 +0100)
committerDan Brown <redacted>
Sun, 2 Jul 2017 15:09:12 +0000 (16:09 +0100)
13 files changed:
resources/assets/js/code.js
resources/assets/js/pages/page-form.js
resources/assets/js/vues/code-editor.js [new file with mode: 0644]
resources/assets/js/vues/vues.js
resources/assets/sass/_codemirror.scss
resources/assets/sass/_components.scss
resources/assets/sass/_text.scss
resources/lang/en/components.php
resources/views/books/show.blade.php
resources/views/components/code-editor.blade.php [new file with mode: 0644]
resources/views/pages/edit.blade.php
tests/BrowserKitTest.php
tests/Permissions/RolesTest.php

index 020c38365dd79b02a96aeecf51edbd85e073cdd0..777baf81dfc5edfa0ab1d9574dd7a766de13c16e 100644 (file)
@@ -1,5 +1,6 @@
 require('codemirror/mode/css/css');
 require('codemirror/mode/clike/clike');
+require('codemirror/mode/diff/diff');
 require('codemirror/mode/go/go');
 require('codemirror/mode/htmlmixed/htmlmixed');
 require('codemirror/mode/javascript/javascript');
@@ -26,6 +27,7 @@ const modeMap = {
     'c++': 'clike',
     'c#': 'clike',
     csharp: 'clike',
+    diff: 'diff',
     go: 'go',
     html: 'htmlmixed',
     javascript: 'javascript',
@@ -42,6 +44,7 @@ const modeMap = {
     ruby: 'ruby',
     rb: 'ruby',
     shell: 'shell',
+    sh: 'shell',
     bash: 'shell',
     toml: 'toml',
     sql: 'sql',
@@ -52,45 +55,120 @@ const modeMap = {
 
 module.exports.highlight = function() {
     let codeBlocks = document.querySelectorAll('.page-content pre');
-
     for (let i = 0; i < codeBlocks.length; i++) {
-        let innerCodeElem = codeBlocks[i].querySelector('code[class^=language-]');
-        let mode = '';
-        if (innerCodeElem !== null) {
-            let langName = innerCodeElem.className.replace('language-', '');
-            if (typeof modeMap[langName] !== 'undefined') mode = modeMap[langName];
-        }
-        codeBlocks[i].innerHTML = codeBlocks[i].innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
-        let content = codeBlocks[i].textContent;
-        console.log('MODE', mode);
-
-        CodeMirror(function(elt) {
-            codeBlocks[i].parentNode.replaceChild(elt, codeBlocks[i]);
-        }, {
-            value: content,
-            mode:  mode,
-            lineNumbers: true,
-            theme: 'base16-light',
-            readOnly: true
-        });
+        highlightElem(codeBlocks[i]);
+    }
+};
+
+function highlightElem(elem) {
+    let innerCodeElem = elem.querySelector('code[class^=language-]');
+    let mode = '';
+    if (innerCodeElem !== null) {
+        let langName = innerCodeElem.className.replace('language-', '');
+        mode = getMode(langName);
     }
+    elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
+    let content = elem.textContent;
+
+    CodeMirror(function(elt) {
+        elem.parentNode.replaceChild(elt, elem);
+    }, {
+        value: content,
+        mode:  mode,
+        lineNumbers: true,
+        theme: 'base16-light',
+        readOnly: true
+    });
+}
+
+/**
+ * Search for a codemirror code based off a user suggestion
+ * @param suggestion
+ * @returns {string}
+ */
+function getMode(suggestion) {
+    suggestion = suggestion.trim().replace(/^\./g, '').toLowerCase();
+    return (typeof modeMap[suggestion] !== 'undefined') ? modeMap[suggestion] : '';
+}
+
+module.exports.highlightElem = highlightElem;
+
+module.exports.wysiwygView = function(elem) {
+    let doc = elem.ownerDocument;
+    let codeElem = elem.querySelector('code');
+
+    let lang = (elem.className || '').replace('language-', '');
+    if (lang === '' && codeElem) {
+        lang = (codeElem.className || '').replace('language-', '')
+    }
+
+    elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
+    let content = elem.textContent;
+    let newWrap = doc.createElement('div');
+    let newTextArea = doc.createElement('textarea');
+
+    newWrap.className = 'CodeMirrorContainer';
+    newWrap.setAttribute('data-lang', lang);
+    newTextArea.style.display = 'none';
+    elem.parentNode.replaceChild(newWrap, elem);
+
+    newWrap.appendChild(newTextArea);
+    newWrap.contentEditable = false;
+    newTextArea.textContent = content;
+
+    let cm = CodeMirror(function(elt) {
+        newWrap.appendChild(elt);
+    }, {
+        value: content,
+        mode:  getMode(lang),
+        lineNumbers: true,
+        theme: 'base16-light',
+        readOnly: true
+    });
+    setTimeout(() => {
+        cm.refresh();
+    }, 300);
+    return {wrap: newWrap, editor: cm};
+};
+
+module.exports.popupEditor = function(elem, modeSuggestion) {
+    let content = elem.textContent;
 
+    return CodeMirror(function(elt) {
+        elem.parentNode.insertBefore(elt, elem);
+        elem.style.display = 'none';
+    }, {
+        value: content,
+        mode:  getMode(modeSuggestion),
+        lineNumbers: true,
+        theme: 'base16-light',
+        lineWrapping: true
+    });
+};
+
+module.exports.setMode = function(cmInstance, modeSuggestion) {
+      cmInstance.setOption('mode', getMode(modeSuggestion));
+};
+module.exports.setContent = function(cmInstance, codeContent) {
+    cmInstance.setValue(codeContent);
+    setTimeout(() => {
+        cmInstance.refresh();
+    }, 10);
 };
 
 module.exports.markdownEditor = function(elem) {
     let content = elem.textContent;
 
-    let cm = CodeMirror(function(elt) {
+    return CodeMirror(function (elt) {
         elem.parentNode.insertBefore(elt, elem);
         elem.style.display = 'none';
     }, {
         value: content,
-        mode:  "markdown",
+        mode: "markdown",
         lineNumbers: true,
         theme: 'base16-light',
         lineWrapping: true
     });
-    return cm;
 
 };
 
index 04951b174e02998f9ba2681d2697d18510a90892..9b3c90a2827af121ec7beaa1f97ee7bc2e310bbc 100644 (file)
@@ -1,5 +1,7 @@
 "use strict";
 
+const Code = require('../code');
+
 /**
  * Handle pasting images from clipboard.
  * @param e  - event
@@ -56,17 +58,132 @@ function registerEditorShortcuts(editor) {
     // Other block shortcuts
     editor.addShortcut('meta+q', '', ['FormatBlock', false, 'blockquote']);
     editor.addShortcut('meta+d', '', ['FormatBlock', false, 'p']);
-    editor.addShortcut('meta+e', '', ['FormatBlock', false, 'pre']);
+    editor.addShortcut('meta+e', '', ['codeeditor', false, 'pre']);
     editor.addShortcut('meta+shift+E', '', ['FormatBlock', false, 'code']);
 }
 
+
+/**
+ * Create and enable our custom code plugin
+ */
+function codePlugin() {
+
+    function elemIsCodeBlock(elem) {
+        return elem.className === 'CodeMirrorContainer';
+    }
+
+    function showPopup(editor) {
+        let selectedNode = editor.selection.getNode();
+        console.log('show ppoe');
+
+        if (!elemIsCodeBlock(selectedNode)) {
+            let providedCode = editor.selection.getNode().textContent;
+            window.vues['code-editor'].open(providedCode, '', (code, lang) => {
+                let wrap = document.createElement('div');
+                wrap.innerHTML = `<pre><code class="language-${lang}"></code></pre>`;
+                wrap.querySelector('code').innerText = code;
+
+                editor.formatter.toggle('pre');
+                let node = editor.selection.getNode();
+                editor.dom.setHTML(node, wrap.querySelector('pre').innerHTML);
+                editor.fire('SetContent');
+            });
+            return;
+        }
+
+        let lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : '';
+        let currentCode = selectedNode.querySelector('textarea').textContent;
+
+        window.vues['code-editor'].open(currentCode, lang, (code, lang) => {
+            let editorElem = selectedNode.querySelector('.CodeMirror');
+            let cmInstance = editorElem.CodeMirror;
+            if (cmInstance) {
+                Code.setContent(cmInstance, code);
+                Code.setMode(cmInstance, lang);
+            }
+            let textArea = selectedNode.querySelector('textarea');
+            if (textArea) textArea.textContent = code;
+            selectedNode.setAttribute('data-lang', lang);
+        });
+    }
+
+    function codeMirrorContainerToPre($codeMirrorContainer) {
+        let textArea = $codeMirrorContainer[0].querySelector('textarea');
+        let code = textArea.textContent;
+        let lang = $codeMirrorContainer[0].getAttribute('data-lang');
+
+        $codeMirrorContainer.removeAttr('contentEditable');
+        let $pre = $('<pre></pre>');
+        $pre.append($('<code></code>').each((index, elem) => {
+            // Needs to be textContent since innerText produces BR:s
+            elem.textContent = code;
+        }).attr('class', `language-${lang}`));
+        $codeMirrorContainer.replaceWith($pre);
+    }
+
+    window.tinymce.PluginManager.add('codeeditor', (editor, url) => {
+
+        let $ = editor.$;
+
+        editor.addButton('codeeditor', {
+            text: 'Code block',
+            icon: false,
+            cmd: 'codeeditor'
+        });
+
+        editor.addCommand('codeeditor', () => {
+            showPopup(editor);
+        });
+
+        // Convert
+        editor.on('PreProcess', function (e) {
+            $('div.CodeMirrorContainer', e.node).
+            each((index, elem) => {
+                let $elem = $(elem);
+                codeMirrorContainerToPre($elem);
+            });
+        });
+
+        editor.on('dblclick', event => {
+            let selectedNode = editor.selection.getNode();
+            if (!elemIsCodeBlock(selectedNode)) return;
+            showPopup(editor);
+        });
+
+        editor.on('SetContent', function () {
+
+            // Recover broken codemirror instances
+            $('.CodeMirrorContainer').filter((index ,elem) => {
+                return typeof elem.querySelector('.CodeMirror').CodeMirror === 'undefined';
+            }).each((index, elem) => {
+                console.log('COVERT');
+                codeMirrorContainerToPre($(elem));
+            });
+
+            let codeSamples = $('body > pre').filter((index, elem) => {
+                return elem.contentEditable !== "false";
+            });
+
+            if (!codeSamples.length) return;
+            editor.undoManager.transact(function () {
+                codeSamples.each((index, elem) => {
+                    Code.wysiwygView(elem);
+                });
+            });
+        });
+
+    });
+}
+
 module.exports = function() {
+    codePlugin();
     let settings = {
         selector: '#html-editor',
         content_css: [
             window.baseUrl('/css/styles.css'),
             window.baseUrl('/libs/material-design-iconic-font/css/material-design-iconic-font.min.css')
         ],
+        branding: false,
         body_class: 'page-content',
         browser_spellcheck: true,
         relative_urls: false,
@@ -77,10 +194,10 @@ module.exports = function() {
         paste_data_images: false,
         extended_valid_elements: 'pre[*]',
         automatic_uploads: false,
-        valid_children: "-div[p|pre|h1|h2|h3|h4|h5|h6|blockquote]",
-        plugins: "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codesample",
+        valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre]",
+        plugins: "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor",
         imagetools_toolbar: 'imageoptions',
-        toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen codesample",
+        toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen",
         content_style: "body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}",
         style_formats: [
             {title: "Header Large", format: "h2"},
@@ -89,17 +206,18 @@ module.exports = function() {
             {title: "Header Tiny", format: "h5"},
             {title: "Paragraph", format: "p", exact: true, classes: ''},
             {title: "Blockquote", format: "blockquote"},
-            {title: "Code Block", icon: "code", format: "pre"},
+            {title: "Code Block", icon: "code", cmd: 'codeeditor', format: 'codeeditor'},
             {title: "Inline Code", icon: "code", inline: "code"},
             {title: "Callouts", items: [
                 {title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}},
                 {title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}},
                 {title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}},
                 {title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}}
-            ]}
+            ]},
         ],
         style_formats_merge: false,
         formats: {
+            codeeditor: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div'},
             alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
             aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
             alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'},
diff --git a/resources/assets/js/vues/code-editor.js b/resources/assets/js/vues/code-editor.js
new file mode 100644 (file)
index 0000000..35a98cc
--- /dev/null
@@ -0,0 +1,43 @@
+const codeLib = require('../code');
+
+const methods = {
+    show() {
+        if (!this.editor) this.editor = codeLib.popupEditor(this.$refs.editor, this.language);
+        this.$refs.overlay.style.display = 'flex';
+    },
+    hide() {
+        this.$refs.overlay.style.display = 'none';
+    },
+    updateEditorMode(language) {
+        codeLib.setMode(this.editor, language);
+    },
+    updateLanguage(lang) {
+        this.language = lang;
+        this.updateEditorMode(lang);
+    },
+    open(code, language, callback) {
+        this.show();
+        this.updateEditorMode(language);
+        this.language = language;
+        codeLib.setContent(this.editor, code);
+        this.code = code;
+        this.callback = callback;
+    },
+    save() {
+        if (!this.callback) return;
+        this.callback(this.editor.getValue(), this.language);
+        this.hide();
+    }
+};
+
+const data = {
+    editor: null,
+    language: '',
+    code: '',
+    callback: null
+};
+
+module.exports = {
+    methods,
+    data
+};
\ No newline at end of file
index 8cc1dd6560ccfc175e846cf9316a8c6ac7aceb0e..31d833bfb864a8308e87a062f0a353e2073f35fc 100644 (file)
@@ -7,12 +7,15 @@ function exists(id) {
 let vueMapping = {
     'search-system': require('./search'),
     'entity-dashboard': require('./entity-search'),
+    'code-editor': require('./code-editor')
 };
 
+window.vues = {};
+
 Object.keys(vueMapping).forEach(id => {
     if (exists(id)) {
         let config = vueMapping[id];
         config.el = '#' + id;
-        new Vue(config);
+        window.vues[id] = new Vue(config);
     }
 });
\ No newline at end of file
index 9f9e38f55c35a1709d172eef767e757bb818ee6e..bd85218a5dd581285101d9fc593b7f14d8eede7f 100644 (file)
@@ -248,6 +248,10 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
   -webkit-tap-highlight-color: transparent;
   -webkit-font-variant-ligatures: contextual;
   font-variant-ligatures: contextual;
+  &:after {
+    content: none;
+    display: none;
+  }
 }
 .CodeMirror-wrap pre {
   word-wrap: break-word;
index 5328057d9da7af1d81767d2fd1a05d8d6b486541..12babae7313efc3d9503cfb4e31af89ee8c1834a 100644 (file)
@@ -466,4 +466,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 
 .image-picker .none {
   display: none;
+}
+
+#code-editor .CodeMirror {
+  height: 400px;
+}
+
+#code-editor .lang-options {
+  max-width: 400px;
+  margin-bottom: $-s;
+  a {
+    margin-right: $-xs;
+    text-decoration: underline;
+  }
 }
\ No newline at end of file
index 4eaa492e735e74cd47b99a2f0173236f0d90f577..ccef2a70faef04521b85d913c5649003edcf7a68 100644 (file)
@@ -135,6 +135,21 @@ pre {
   font-size: 12px;
   background-color: #f5f5f5;
   border: 1px solid #DDD;
+  padding-left: 31px;
+  position: relative;
+  padding-top: 3px;
+  padding-bottom: 3px;
+  &:after {
+    content: '';
+    display: block;
+    position: absolute;
+    top: 0;
+    width: 29px;
+    left: 0;
+    background-color: #f5f5f5;
+    height: 100%;
+    border-right: 1px solid #DDD;
+  }
 }
 
 
@@ -182,6 +197,7 @@ pre code {
   border: 0;
   font-size: 1em;
   display: block;
+  line-height: 1.6;
 }
 /*
  * Text colors
index b9108702a288902c7e49ad46a9e42a023baf5389..334502d05b84dc0e09ec37b863d02d49d1d88e56 100644 (file)
@@ -20,5 +20,13 @@ return [
     'image_preview' => 'Image Preview',
     'image_upload_success' => 'Image uploaded successfully',
     'image_update_success' => 'Image details successfully updated',
-    'image_delete_success' => 'Image successfully deleted'
+    'image_delete_success' => 'Image successfully deleted',
+
+    /**
+     * Code editor
+     */
+    'code_editor' => 'Edit Code',
+    'code_language' => 'Code Language',
+    'code_content' => 'Code Content',
+    'code_save' => 'Save Code',
 ];
\ No newline at end of file
index adfec45256db85b7d915c199a31cc34c9e442f33..ddbe7a0a4ab0488fbe819a9204ccd7824a4f1e84 100644 (file)
                         @else
                             <p class="text-muted">{{ trans('entities.books_empty_contents') }}</p>
                             <p>
+                                @if(userCan('page-create', $book))
                                 <a href="{{ $book->getUrl('/page/create') }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.books_empty_create_page') }}</a>
+                                @endif
+                                @if(userCan('page-create', $book) && userCan('chapter-create', $book))
                                 &nbsp;&nbsp;<em class="text-muted">-{{ trans('entities.books_empty_or') }}-</em>&nbsp;&nbsp;&nbsp;
+                                @endif
+                                @if(userCan('chapter-create', $book))
                                 <a href="{{ $book->getUrl('/chapter/create') }}" class="text-chapter"><i class="zmdi zmdi-collection-bookmark"></i>{{ trans('entities.books_empty_add_chapter') }}</a>
+                                @endif
                             </p>
                             <hr>
                         @endif
diff --git a/resources/views/components/code-editor.blade.php b/resources/views/components/code-editor.blade.php
new file mode 100644 (file)
index 0000000..5a385ef
--- /dev/null
@@ -0,0 +1,51 @@
+<div id="code-editor">
+    <div class="overlay" ref="overlay" v-cloak @click="hide()">
+        <div class="popup-body" @click.stop>
+
+            <div class="popup-header primary-background">
+                <div class="popup-title">{{ trans('components.code_editor') }}</div>
+                <button class="popup-close neg corner-button button" @click="hide()">x</button>
+            </div>
+
+            <div class="padded">
+                <div class="form-group">
+                    <label for="code-editor-language">{{ trans('components.code_language') }}</label>
+                    <div class="lang-options">
+                        <small>
+                            <a @click="updateLanguage('CSS')">CSS</a>
+                            <a @click="updateLanguage('C')">C</a>
+                            <a @click="updateLanguage('C++')">C++</a>
+                            <a @click="updateLanguage('C#')">C#</a>
+                            <a @click="updateLanguage('Go')">Go</a>
+                            <a @click="updateLanguage('HTML')">HTML</a>
+                            <a @click="updateLanguage('Java')">Java</a>
+                            <a @click="updateLanguage('JavaScript')">JavaScript</a>
+                            <a @click="updateLanguage('JSON')">JSON</a>
+                            <a @click="updateLanguage('PHP')">PHP</a>
+                            <a @click="updateLanguage('MarkDown')">MarkDown</a>
+                            <a @click="updateLanguage('Nginx')">Nginx</a>
+                            <a @click="updateLanguage('Python')">Python</a>
+                            <a @click="updateLanguage('Ruby')">Ruby</a>
+                            <a @click="updateLanguage('shell')">Shell/Bash</a>
+                            <a @click="updateLanguage('SQL')">SQL</a>
+                            <a @click="updateLanguage('XML')">XML</a>
+                            <a @click="updateLanguage('YAML')">YAML</a>
+                        </small>
+                    </div>
+                    <input @keypress.enter="save()" id="code-editor-language" type="text" @input="updateEditorMode(language)" v-model="language">
+                </div>
+
+                <div class="form-group">
+                    <label for="code-editor-content">{{ trans('components.code_content') }}</label>
+                    <textarea ref="editor" v-model="code"></textarea>
+                </div>
+
+                <div class="form-group">
+                    <button type="button" class="button pos" @click="save()">{{ trans('components.code_save') }}</button>
+                </div>
+
+            </div>
+
+        </div>
+    </div>
+</div>
\ No newline at end of file
index 5ab25d1cc2110bd312d22a9539eb65c1dad956b2..6de47aaf1a31987e540d62081b471d02a84483e7 100644 (file)
@@ -21,6 +21,7 @@
     </div>
     
     @include('components.image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id])
+    @include('components.code-editor')
     @include('components.entity-selector-popup')
 
 @stop
\ No newline at end of file
index c665bfc231453308a0cd18d33752b834f590b71b..98259dea94b587a7792da7bea8b8c7231ce575d6 100644 (file)
@@ -1,5 +1,6 @@
 <?php namespace Tests;
 
+use BookStack\Entity;
 use BookStack\Role;
 use BookStack\Services\PermissionService;
 use Illuminate\Contracts\Console\Kernel;
@@ -117,6 +118,16 @@ abstract class BrowserKitTest extends TestCase
         ];
     }
 
+    /**
+     * Helper for updating entity permissions.
+     * @param Entity $entity
+     */
+    protected function updateEntityPermissions(Entity $entity)
+    {
+        $restrictionService = $this->app[PermissionService::class];
+        $restrictionService->buildJointPermissionsForEntity($entity);
+    }
+
     /**
      * Quick way to create a new user
      * @param array $attributes
index 83d1b98a8c1395a627a8b35053e08429465b9c19..eda5d092ab0c6a180454d047a3b7b92851852488 100644 (file)
@@ -1,7 +1,10 @@
 <?php namespace Tests;
 
+use BookStack\Page;
 use BookStack\Repos\PermissionsRepo;
 use BookStack\Role;
+use Laravel\BrowserKitTesting\HttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 
 class RolesTest extends BrowserKitTest
 {
@@ -580,8 +583,6 @@ class RolesTest extends BrowserKitTest
             ->see('Cannot be deleted');
     }
 
-
-
     public function test_image_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['image-update-all']);
@@ -620,4 +621,40 @@ class RolesTest extends BrowserKitTest
             ->dontSeeInDatabase('images', ['id' => $image->id]);
     }
 
+    public function test_role_permission_removal()
+    {
+        // To cover issue fixed in f99c8ff99aee9beb8c692f36d4b84dc6e651e50a.
+        $page = Page::first();
+        $viewerRole = \BookStack\Role::getRole('viewer');
+        $viewer = $this->getViewer();
+        $this->actingAs($viewer)->visit($page->getUrl())->assertResponseOk();
+
+        $this->asAdmin()->put('/settings/roles/' . $viewerRole->id, [
+            'display_name' => $viewerRole->display_name,
+            'description' => $viewerRole->description,
+            'permission' => []
+        ])->assertResponseStatus(302);
+
+        $this->expectException(HttpException::class);
+        $this->actingAs($viewer)->visit($page->getUrl())->assertResponseStatus(404);
+    }
+
+    public function test_empty_state_actions_not_visible_without_permission()
+    {
+        $admin = $this->getAdmin();
+        // Book links
+        $book = factory(\BookStack\Book::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id]);
+        $this->updateEntityPermissions($book);
+        $this->actingAs($this->getViewer())->visit($book->getUrl())
+            ->dontSee('Create a new page')
+            ->dontSee('Add a chapter');
+
+        // Chapter links
+        $chapter = factory(\BookStack\Chapter::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]);
+        $this->updateEntityPermissions($chapter);
+        $this->actingAs($this->getViewer())->visit($chapter->getUrl())
+            ->dontSee('Create a new page')
+            ->dontSee('Sort the current book');
+    }
+
 }