Skip to content

Commit

Permalink
Merge pull request #16814 from ckeditor/ck/6428
Browse files Browse the repository at this point in the history
Feature (ckbox, image): The image upload and edit buttons are disabled if the user has no permission to upload any asset.
  • Loading branch information
niegowski authored Jul 30, 2024
2 parents c4878b7 + c6d42ce commit 5182fda
Show file tree
Hide file tree
Showing 12 changed files with 267 additions and 6 deletions.
1 change: 1 addition & 0 deletions packages/ckeditor5-ckbox/lang/contexts.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"Open file manager": "A toolbar button tooltip for opening the file browser that allows inserting an image or a file to the editor.",
"Cannot determine a category for the uploaded file.": "A message is displayed when CKEditor 5 cannot associate an image with any of the categories defined in CKBox while uploading an asset.",
"Cannot access default workspace.": "A message is displayed when the user is not authorised to access the CKBox workspace configured as default one.",
"No permission for image editing. Try using the file manager or contact your administrator.": "The title of the notification displayed when there is no permission to edit assets.",
"Edit image": "Image toolbar button tooltip for opening a dialog to manipulate the image.",
"Processing the edited image.": "A message stating that image editing is in progress.",
"Server failed to process the image.": "A message is displayed when the server fails to process an image or doesn't respond.",
Expand Down
52 changes: 51 additions & 1 deletion packages/ckeditor5-ckbox/src/ckboxediting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
* @module ckbox/ckboxediting
*/

import type { InitializedToken } from '@ckeditor/ckeditor5-cloud-services';
import { Plugin, type Editor } from 'ckeditor5/src/core.js';
import {
Range,
Expand All @@ -32,6 +31,9 @@ import CKBoxUploadAdapter from './ckboxuploadadapter.js';
import CKBoxUtils from './ckboxutils.js';

import type { ReplaceImageSourceCommand } from '@ckeditor/ckeditor5-image';
import { sendHttpRequest } from './utils.js';

const COMMAND_FORCE_DISABLE_ID = 'NoPermission';

/**
* The CKBox editing feature. It introduces the {@link module:ckbox/ckboxcommand~CKBoxCommand CKBox command} and
Expand Down Expand Up @@ -68,6 +70,13 @@ export default class CKBoxEditing extends Plugin {
if ( isLibraryLoaded() ) {
editor.commands.add( 'ckbox', new CKBoxCommand( editor ) );
}

// Promise is not handled intentionally. Errors should be displayed in console if there are so.
isUploadPermissionGranted( editor ).then( isCreateAssetAllowed => {
if ( !isCreateAssetAllowed ) {
this._blockImageCommands();
}
} );
}

/**
Expand Down Expand Up @@ -100,6 +109,24 @@ export default class CKBoxEditing extends Plugin {
return hasConfiguration || isLibraryLoaded();
}

/**
* Blocks `uploadImage` and `ckboxImageEdit` commands.
*/
private _blockImageCommands(): void {
const editor = this.editor;
const uploadImageCommand = editor.commands.get( 'uploadImage' );
const imageEditingCommand = editor.commands.get( 'ckboxImageEdit' );

if ( uploadImageCommand ) {
uploadImageCommand.isAccessAllowed = false;
uploadImageCommand.forceDisabled( COMMAND_FORCE_DISABLE_ID );
}

if ( imageEditingCommand ) {
imageEditingCommand.forceDisabled( COMMAND_FORCE_DISABLE_ID );
}
}

/**
* Checks if at least one image plugin is loaded.
*/
Expand Down Expand Up @@ -440,3 +467,26 @@ function shouldUpcastAttributeForNode( node: Node ) {
function isLibraryLoaded(): boolean {
return !!window.CKBox;
}

/**
* Checks is access allowed to upload assets.
*/
async function isUploadPermissionGranted( editor: Editor ): Promise<boolean> {
const ckboxUtils = editor.plugins.get( CKBoxUtils );
const origin = editor.config.get( 'ckbox.serviceOrigin' );

const url = new URL( 'permissions', origin );
const { value } = await ckboxUtils.getToken();

const response = ( await sendHttpRequest( {
url,
authorization: value,
signal: ( new AbortController() ).signal // Aborting is unnecessary.
} ) ) as Record<string, CategoryPermission>;

return Object.values( response ).some( category => category[ 'asset:create' ] );
}

type CategoryPermission = {
[ key: string ]: boolean;
};
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,19 @@ export default class CKBoxImageEditUI extends Plugin {

editor.ui.componentFactory.add( 'ckboxImageEdit', locale => {
const command = editor.commands.get( 'ckboxImageEdit' )!;
const uploadImageCommand = editor.commands.get( 'uploadImage' )!;
const view = new ButtonView( locale );
const t = locale.t;

view.set( {
label: t( 'Edit image' ),
icon: ckboxImageEditIcon,
tooltip: true
} );

view.bind( 'label' ).to( uploadImageCommand, 'isAccessAllowed', isAccessAllowed => isAccessAllowed ?
t( 'Edit image' ) :
t( 'No permission for image editing. Try using the file manager or contact your administrator.' )
);
view.bind( 'isOn' ).to( command, 'value', command, 'isEnabled', ( value, isEnabled ) => value && isEnabled );
view.bind( 'isEnabled' ).to( command );

Expand Down
116 changes: 115 additions & 1 deletion packages/ckeditor5-ckbox/tests/ckboxediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import CloudServicesCoreMock from './_utils/cloudservicescoremock.js';
import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view.js';

import CKBoxEditing from '../src/ckboxediting.js';
import CKBoxImageEditing from '../src/ckboximageedit/ckboximageeditediting.js';
import CKBoxCommand from '../src/ckboxcommand.js';
import CKBoxUploadAdapter from '../src/ckboxuploadadapter.js';
import TokenMock from '@ckeditor/ckeditor5-cloud-services/tests/_utils/tokenmock.js';
Expand Down Expand Up @@ -1831,6 +1832,118 @@ describe( 'CKBoxEditing', () => {

expect( element.getAttribute( 'ckboxImageId' ) ).to.be.undefined;
} );

describe( 'permissions', () => {
let sinonXHR;
const CKBOX_API_URL = 'https://upload.example.com';
const CKBOX_TOKEN_URL = 'http://cs.example.com';

beforeEach( () => {
sinonXHR = testUtils.sinon.useFakeServer();
sinonXHR.autoRespond = true;
sinonXHR.respondImmediately = true;
} );

afterEach( () => {
sinonXHR.restore();
} );

it( 'should not disable image upload command if access allowed', async () => {
sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/permissions', [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify( {
'id1': {
'asset:create': true
}
} )
] );

const editor = await createTestEditor( {
ckbox: {
tokenUrl: CKBOX_TOKEN_URL,
serviceOrigin: CKBOX_API_URL
}
} );

const uploadImageCommand = editor.commands.get( 'uploadImage' );

expect( uploadImageCommand.isEnabled ).to.be.true;
expect( uploadImageCommand.isAccessAllowed ).to.be.true;
} );

it( 'should disable image upload command if access not allowed', async () => {
sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/permissions', [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify( {
'id1': {
'asset:create': false
}
} )
] );

const editor = await createTestEditor( {
ckbox: {
tokenUrl: CKBOX_TOKEN_URL,
serviceOrigin: CKBOX_API_URL
}
} );

const uploadImageCommand = editor.commands.get( 'uploadImage' );

expect( uploadImageCommand.isEnabled ).to.be.false;
expect( uploadImageCommand.isAccessAllowed ).to.be.false;
} );

it( 'should not disable image upload command if access allowed ( CKBox loaded first )', async () => {
sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/permissions', [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify( {
'id1': {
'asset:create': true
}
} )
] );

const editor = await createTestEditor( {
ckbox: {
tokenUrl: CKBOX_TOKEN_URL,
serviceOrigin: CKBOX_API_URL
}
}, true );

const uploadImageCommand = editor.commands.get( 'uploadImage' );

expect( uploadImageCommand.isEnabled ).to.be.true;
expect( uploadImageCommand.isAccessAllowed ).to.be.true;
} );

it( 'should disable image upload command if access not allowed ( CKBox loaded first )', async () => {
sinonXHR.respondWith( 'GET', CKBOX_API_URL + '/permissions', [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify( {
'id1': {
'asset:create': false
}
} )
] );

const editor = await createTestEditor( {
ckbox: {
tokenUrl: CKBOX_TOKEN_URL,
serviceOrigin: CKBOX_API_URL
}
}, true );

const uploadImageCommand = editor.commands.get( 'uploadImage' );

expect( uploadImageCommand.isEnabled ).to.be.false;
expect( uploadImageCommand.isAccessAllowed ).to.be.false;
} );
} );
} );

function createTestEditor( config = {}, loadCKBoxFirst = false ) {
Expand All @@ -1845,7 +1958,8 @@ function createTestEditor( config = {}, loadCKBoxFirst = false ) {
ImageUploadEditing,
ImageUploadProgress,
CloudServices,
CKBoxUploadAdapter
CKBoxUploadAdapter,
CKBoxImageEditing
];

if ( loadCKBoxFirst ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ describe( 'CKBoxImageEditUI', () => {
expect( button.label ).to.equal( 'Edit image' );
} );

it( 'should have a label binded to #isAccessAllowed', () => {
const uploadImageCommand = editor.commands.get( 'uploadImage' );
uploadImageCommand.set( 'isAccessAllowed', false );

expect( button.label ).to.equal( 'No permission for image editing. Try ' +
'using the file manager or contact your administrator.' );
} );

it( 'should have an icon', () => {
expect( button.icon ).to.match( /^<svg/ );
} );
Expand Down
1 change: 1 addition & 0 deletions packages/ckeditor5-image/lang/contexts.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"From computer": "The label for the upload image from computer menu bar button (inside 'Image' menu).",
"Replace image from computer": "The label for the replace image by upload from computer toolbar button.",
"Upload failed": "The title of the notification displayed when upload fails.",
"No permission to upload from computer. Try using the file manager or contact your administrator.": "The title of the notification displayed when there is no permission to upload assets.",
"Image toolbar": "The label used by assistive technologies describing an image toolbar attached to an image widget.",
"Resize image": "The label used for the dropdown in the image toolbar containing defined resize options.",
"Resize image to %0": "The label used for the standalone resize options buttons in the image toolbar.",
Expand Down
12 changes: 12 additions & 0 deletions packages/ckeditor5-image/src/imageupload/imageuploadediting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ export default class ImageUploadEditing extends Plugin {

editor.execute( 'uploadImage', { file: images } );
} );

const uploadImageCommand = editor.commands.get( 'uploadImage' )!;

if ( !uploadImageCommand.isAccessAllowed ) {
const notification: Notification = editor.plugins.get( 'Notification' );
const t = editor.locale.t;

// eslint-disable-next-line max-len
notification.showWarning( t( 'No permission to upload from computer. Try using the file manager or contact your administrator.' ), {
namespace: 'image'
} );
}
} );

// Handle HTML pasted with images with base64 or blob sources.
Expand Down
11 changes: 10 additions & 1 deletion packages/ckeditor5-image/src/imageupload/imageuploadui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,23 @@ export default class ImageUploadUI extends Plugin {
private _createToolbarButton(): ButtonView {
const t = this.editor.locale.t;
const imageInsertUI: ImageInsertUI = this.editor.plugins.get( 'ImageInsertUI' );
const uploadImageCommand = this.editor.commands.get( 'uploadImage' )!;

const button = this._createButton( FileDialogButtonView );

button.tooltip = true;
button.bind( 'label' ).to(
imageInsertUI,
'isImageSelected',
isImageSelected => isImageSelected ? t( 'Replace image from computer' ) : t( 'Upload image from computer' )
uploadImageCommand,
'isAccessAllowed',
( isImageSelected, isAccessAllowed ) => {
if ( !isAccessAllowed ) {
return t( 'No permission to upload from computer. Try using the file manager or contact your administrator.' );
}

return isImageSelected ? t( 'Replace image from computer' ) : t( 'Upload image from computer' );
}
);

return button;
Expand Down
22 changes: 21 additions & 1 deletion packages/ckeditor5-image/src/imageupload/uploadimagecommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { FileRepository } from 'ckeditor5/src/upload.js';
import { Command } from 'ckeditor5/src/core.js';
import { Command, type Editor } from 'ckeditor5/src/core.js';
import { toArray, type ArrayOrItem } from 'ckeditor5/src/utils.js';
import type { Position } from 'ckeditor5/src/engine.js';

Expand Down Expand Up @@ -46,6 +46,26 @@ import type ImageUtils from '../imageutils.js';
* ```
*/
export default class UploadImageCommand extends Command {
/**
* The command property: `false` if there is no permission on image upload, otherwise `true`.
*
* @observable
* @internal
*/
declare public isAccessAllowed: boolean;

/**
* Creates an instance of the `imageUlpoad` command. When executed, the command upload one of
* the currently selected image from computer.
*
* @param editor The editor instance.
*/
constructor( editor: Editor ) {
super( editor );

this.set( 'isAccessAllowed', true );
}

/**
* @inheritDoc
*/
Expand Down
24 changes: 24 additions & 0 deletions packages/ckeditor5-image/tests/imageupload/imageuploadediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,30 @@ describe( 'ImageUploadEditing', () => {
);
} );

it( 'should display notification when no permission to upload from computer.', done => {
const files = [ createNativeFileMock(), createNativeFileMock() ];
const dataTransfer = new DataTransfer( { files, types: [ 'Files' ] } );
const uploadImageCommand = editor.commands.get( 'uploadImage' );
const notification = editor.plugins.get( Notification );

notification.on( 'show:warning', ( evt, data ) => {
tryExpect( done, () => {
expect( data.message ).to.equal( 'No permission to upload from computer. Try using the file manager ' +
'or contact your administrator.' );
evt.stop();
} );
}, { priority: 'high' } );

uploadImageCommand.set( 'isAccessAllowed', false );

setModelData( model, '[]' );

const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
const targetViewRange = editor.editing.mapper.toViewRange( targetRange );

viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
} );

it( 'should insert image when is pasted on allowed position when UploadImageCommand is enabled', () => {
setModelData( model, '<paragraph>foo</paragraph>[<imageBlock></imageBlock>]' );

Expand Down
Loading

0 comments on commit 5182fda

Please sign in to comment.