Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Read Only Fields Based on User Group #1389

Open
wants to merge 6 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/app/css/style.css

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions assets/app/css/style.less
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ cp-fieldcontainer {
}

cp-field.uk-disabled {
cursor: not-allowed;
}

cp-field.uk-disabled > * {
opacity: .6;
}

Expand Down Expand Up @@ -263,8 +267,8 @@ cp-gravatar canvas {
border: none !important;
}

.mce-fullscreen {
z-index: 981 !important;
.mce-fullscreen {
z-index: 981 !important;
}

.uk-htmleditor-content,
Expand Down
6 changes: 5 additions & 1 deletion modules/Cockpit/assets/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,10 @@ riot.tag2('cp-field', '<div ref="field" data-is="{\'field-\'+opts.type}" bind="{

if (o.disabled) {
this.root.classList.add('uk-disabled');
let elms = this.root.querySelectorAll('input, button, select, textarea, a');
for(let x of elms){
x.tabIndex = -1;
}
}

this.parent.update();
Expand Down Expand Up @@ -783,7 +787,7 @@ riot.tag2('cp-diff', '<div class="uk-overflow-container"> <div><pre ref="canvas"

});

riot.tag2('cp-fieldsmanager', '<div ref="fieldscontainer" class="uk-sortable uk-grid uk-grid-small uk-grid-gutter uk-form"> <div class="uk-width-{field.width}" data-idx="{idx}" each="{field,idx in fields}"> <div class="uk-panel uk-panel-box uk-panel-card"> <div class="uk-grid uk-grid-small"> <div class="uk-flex-item-1 uk-flex"> <input class="uk-flex-item-1 uk-form-small uk-form-blank" type="text" bind="fields[{idx}].name" placeholder="name" pattern="[a-zA-Z0-9_]+" required> </div> <div class="uk-width-1-4"> <div class="uk-form-select" data-uk-form-select> <div class="uk-form-icon"> <i class="uk-icon-arrows-h"></i> <input class="uk-width-1-1 uk-form-small uk-form-blank" riot-value="{field.width}"> </div> <select bind="fields[{idx}].width"> <option value="1-1">1-1</option> <option value="1-2">1-2</option> <option value="1-3">1-3</option> <option value="2-3">2-3</option> <option value="1-4">1-4</option> <option value="3-4">3-4</option> </select> </div> </div> <div class="uk-text-right"> <ul class="uk-subnav"> <li if="{parent.opts.listoption}"> <a class="uk-text-{field.lst ? \'success\':\'muted\'}" onclick="{parent.togglelist}" title="{App.i18n.get(\'Show field on list view\')}"> <i class="uk-icon-list"></i> </a> </li> <li> <a onclick="{parent.fieldSettings}"><i class="uk-icon-cog uk-text-primary"></i></a> </li> <li> <a class="uk-text-danger" onclick="{parent.removefield}"> <i class="uk-icon-trash"></i> </a> </li> </ul> </div> </div> </div> </div> </div> <div class="uk-modal uk-sortable-nodrag" ref="modalField"> <div class="uk-modal-dialog uk-modal-dialog-large" if="{field}"> <div class="uk-form-row uk-text-large uk-text-bold"> {field.name || \'Field\'} </div> <div class="uk-tab uk-flex uk-flex-center uk-margin" data-uk-tab> <li class="uk-active"><a>{App.i18n.get(\'General\')}</a></li> <li><a>{App.i18n.get(\'Access\')}</a></li> </div> <div class="uk-margin-top ref-tab"> <div> <div class="uk-form-row"> <label class="uk-text-muted uk-text-small">{App.i18n.get(\'Field Type\')}:</label> <div class="uk-form-select uk-width-1-1 uk-margin-small-top"> <a class="uk-text-capitalize">{field.type}</a> <select class="uk-width-1-1 uk-text-capitalize" bind="field.type"> <option each="{type,typeidx in fieldtypes}" riot-value="{type.value}">{type.name}</option> </select> </div> </div> <div class="uk-form-row"> <label class="uk-text-muted uk-text-small">{App.i18n.get(\'Field Label\')}:</label> <input class="uk-width-1-1 uk-margin-small-top" type="text" bind="field.label" placeholder="{App.i18n.get(\'Label\')}"> </div> <div class="uk-form-row"> <label class="uk-text-muted uk-text-small">{App.i18n.get(\'Field Info\')}:</label> <input class="uk-width-1-1 uk-margin-small-top" type="text" bind="field.info" placeholder="{App.i18n.get(\'Info\')}"> </div> <div class="uk-form-row"> <label class="uk-text-muted uk-text-small">{App.i18n.get(\'Field Group\')}:</label> <input class="uk-width-1-1 uk-margin-small-top" type="text" bind="field.group" placeholder="{App.i18n.get(\'Group name\')}"> </div> <div class="uk-form-row"> <label class="uk-text-small uk-text-bold uk-margin-small-bottom">{App.i18n.get(\'Options\')} <span class="uk-text-muted">JSON</span></label> <field-object cls="uk-width-1-1" bind="field.options" rows="6" allowtabs="2"></field-object> </div> <div class="uk-form-row"> <field-boolean bind="field.required" label="{App.i18n.get(\'Required\')}"></field-boolean> </div> <div class="uk-form-row" if="{opts.localize !== false}"> <field-boolean bind="field.localize" label="{App.i18n.get(\'Localize\')}"></field-boolean> </div> </div> <div class="uk-hidden"> <field-access-list class="uk-margin-large uk-margin-large-top uk-display-block" bind="field.acl"></field-access-list> </div> </div> <div class="uk-modal-footer uk-text-right"><button class="uk-button uk-button-large uk-button-link uk-modal-close">{App.i18n.get(\'Close\')}</button></div> </div> </div> <div class="uk-margin-top" show="{fields.length}"> <a class="uk-button uk-button-outline uk-text-primary" onclick="{addfield}"><i class="uk-icon-plus-circle"></i> {App.i18n.get(\'Add field\')}</a> </div> <div class="uk-width-medium-1-3 uk-viewport-height-1-3 uk-container-center uk-text-center uk-flex uk-flex-middle" if="{!fields.length && !reorder}"> <div class="uk-animation-fade"> <p class="uk-text-xlarge"> <img riot-src="{App.base(\'/assets/app/media/icons/form-editor.svg\')}" width="100" height="100"> </p> <hr> {App.i18n.get(\'No fields added yet\')}. <span data-uk-dropdown="pos:\'bottom-center\'"> <a onclick="{addfield}">{App.i18n.get(\'Add field\')}.</a> <div class="uk-dropdown uk-dropdown-scrollable uk-text-left" if="{opts.templates && opts.templates.length}"> <ul class="uk-nav uk-nav-dropdown"> <li class="uk-nav-header">{App.i18n.get(\'Choose from template\')}</li> <li each="{template in opts.templates}"> <a onclick="{parent.fromTemplate.bind(parent, template)}"><i class="uk-icon-sliders uk-margin-small-right"></i> {template.label || template.name}</a> </li> </ul> </div> <span> </div> </div>', '', '', function(opts) {
riot.tag2('cp-fieldsmanager', '<div ref="fieldscontainer" class="uk-sortable uk-grid uk-grid-small uk-grid-gutter uk-form"> <div class="uk-width-{field.width}" data-idx="{idx}" each="{field,idx in fields}"> <div class="uk-panel uk-panel-box uk-panel-card"> <div class="uk-grid uk-grid-small"> <div class="uk-flex-item-1 uk-flex"> <input class="uk-flex-item-1 uk-form-small uk-form-blank" type="text" bind="fields[{idx}].name" placeholder="name" pattern="[a-zA-Z0-9_]+" required> </div> <div class="uk-width-1-4"> <div class="uk-form-select" data-uk-form-select> <div class="uk-form-icon"> <i class="uk-icon-arrows-h"></i> <input class="uk-width-1-1 uk-form-small uk-form-blank" riot-value="{field.width}"> </div> <select bind="fields[{idx}].width"> <option value="1-1">1-1</option> <option value="1-2">1-2</option> <option value="1-3">1-3</option> <option value="2-3">2-3</option> <option value="1-4">1-4</option> <option value="3-4">3-4</option> </select> </div> </div> <div class="uk-text-right"> <ul class="uk-subnav"> <li if="{parent.opts.listoption}"> <a class="uk-text-{field.lst ? \'success\':\'muted\'}" onclick="{parent.togglelist}" title="{App.i18n.get(\'Show field on list view\')}"> <i class="uk-icon-list"></i> </a> </li> <li> <a onclick="{parent.fieldSettings}"><i class="uk-icon-cog uk-text-primary"></i></a> </li> <li> <a class="uk-text-danger" onclick="{parent.removefield}"> <i class="uk-icon-trash"></i> </a> </li> </ul> </div> </div> </div> </div> </div> <div class="uk-modal uk-sortable-nodrag" ref="modalField"> <div class="uk-modal-dialog uk-modal-dialog-large" if="{field}"> <div class="uk-form-row uk-text-large uk-text-bold"> {field.name || \'Field\'} </div> <div class="uk-tab uk-flex uk-flex-center uk-margin" data-uk-tab> <li class="uk-active"><a>{App.i18n.get(\'General\')}</a></li> <li><a>{App.i18n.get(\'Access\')}</a></li> </div> <div class="uk-margin-top ref-tab"> <div> <div class="uk-form-row"> <label class="uk-text-muted uk-text-small">{App.i18n.get(\'Field Type\')}:</label> <div class="uk-form-select uk-width-1-1 uk-margin-small-top"> <a class="uk-text-capitalize">{field.type}</a> <select class="uk-width-1-1 uk-text-capitalize" bind="field.type"> <option each="{type,typeidx in fieldtypes}" riot-value="{type.value}">{type.name}</option> </select> </div> </div> <div class="uk-form-row"> <label class="uk-text-muted uk-text-small">{App.i18n.get(\'Field Label\')}:</label> <input class="uk-width-1-1 uk-margin-small-top" type="text" bind="field.label" placeholder="{App.i18n.get(\'Label\')}"> </div> <div class="uk-form-row"> <label class="uk-text-muted uk-text-small">{App.i18n.get(\'Field Info\')}:</label> <input class="uk-width-1-1 uk-margin-small-top" type="text" bind="field.info" placeholder="{App.i18n.get(\'Info\')}"> </div> <div class="uk-form-row"> <label class="uk-text-muted uk-text-small">{App.i18n.get(\'Field Group\')}:</label> <input class="uk-width-1-1 uk-margin-small-top" type="text" bind="field.group" placeholder="{App.i18n.get(\'Group name\')}"> </div> <div class="uk-form-row"> <label class="uk-text-small uk-text-bold uk-margin-small-bottom">{App.i18n.get(\'Options\')} <span class="uk-text-muted">JSON</span></label> <field-object cls="uk-width-1-1" bind="field.options" rows="6" allowtabs="2"></field-object> </div> <div class="uk-form-row"> <field-boolean bind="field.required" label="{App.i18n.get(\'Required\')}"></field-boolean> </div> <div class="uk-form-row" if="{opts.localize !== false}"> <field-boolean bind="field.localize" label="{App.i18n.get(\'Localize\')}"></field-boolean> </div> </div> <div class="uk-hidden"> <h3>Read/Write Access</h3> <field-access-list class="uk-margin-large uk-margin-large-top uk-display-block" bind="field.acl"></field-access-list> <h3>Read Only Access</h3> <field-access-list class="uk-margin-large uk-margin-large-top uk-display-block" bind="field.acl_ro"></field-access-list> </div> </div> <div class="uk-modal-footer uk-text-right"><button class="uk-button uk-button-large uk-button-link uk-modal-close">{App.i18n.get(\'Close\')}</button></div> </div> </div> <div class="uk-margin-top" show="{fields.length}"> <a class="uk-button uk-button-outline uk-text-primary" onclick="{addfield}"><i class="uk-icon-plus-circle"></i> {App.i18n.get(\'Add field\')}</a> </div> <div class="uk-width-medium-1-3 uk-viewport-height-1-3 uk-container-center uk-text-center uk-flex uk-flex-middle" if="{!fields.length && !reorder}"> <div class="uk-animation-fade"> <p class="uk-text-xlarge"> <img riot-src="{App.base(\'/assets/app/media/icons/form-editor.svg\')}" width="100" height="100"> </p> <hr> {App.i18n.get(\'No fields added yet\')}. <span data-uk-dropdown="pos:\'bottom-center\'"> <a onclick="{addfield}">{App.i18n.get(\'Add field\')}.</a> <div class="uk-dropdown uk-dropdown-scrollable uk-text-left" if="{opts.templates && opts.templates.length}"> <ul class="uk-nav uk-nav-dropdown"> <li class="uk-nav-header">{App.i18n.get(\'Choose from template\')}</li> <li each="{template in opts.templates}"> <a onclick="{parent.fromTemplate.bind(parent, template)}"><i class="uk-icon-sliders uk-margin-small-right"></i> {template.label || template.name}</a> </li> </ul> </div> <span> </div> </div>', '', '', function(opts) {

riot.util.bind(this);

Expand Down
4 changes: 4 additions & 0 deletions modules/Cockpit/assets/components/cp-components.tag
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@

if (o.disabled) {
this.root.classList.add('uk-disabled');
let elms = this.root.querySelectorAll('input, button, select, textarea, a');
for(let x of elms){
x.tabIndex = -1;
}
}

this.parent.update();
Expand Down
3 changes: 3 additions & 0 deletions modules/Cockpit/assets/components/cp-fieldsmanager.tag
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@

</div>
<div class="uk-hidden">
<h3>Read/Write Access</h3>
<field-access-list class="uk-margin-large uk-margin-large-top uk-display-block" bind="field.acl"></field-access-list>
<h3>Read Only Access</h3>
<field-access-list class="uk-margin-large uk-margin-large-top uk-display-block" bind="field.acl_ro"></field-access-list>
</div>
</div>

Expand Down
32 changes: 26 additions & 6 deletions modules/Collections/Controller/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -302,14 +302,15 @@ public function save_entry($collection) {

$entry['_mby'] = $this->module('cockpit')->getUser('_id');

$old_entry = [];
if (isset($entry['_id'])) {

if (!$this->app->helper('admin')->isResourceEditableByCurrentUser($entry['_id'])) {
$this->stop(['error' => "Saving failed! Entry is locked!"], 412);
}

$_entry = $this->module('collections')->findOne($collection['name'], ['_id' => $entry['_id']]);
$revision = !(json_encode($_entry) == json_encode($entry));
$old_entry = $this->module('collections')->findOne($collection['name'], ['_id' => $entry['_id']]);
$revision = !(json_encode($old_entry) == json_encode($entry));

} else {

Expand All @@ -322,15 +323,34 @@ public function save_entry($collection) {

}

// ensure we only modify fields which user has access to
$new_entry = $entry;
$user = $this->app->module('cockpit')->getUser();
foreach ($collection['fields'] as $field) {
$hasAccess = false;
if (!$field['acl'] && !$field['acl_ro']) {
$hasAccess = true;
} else {
if($user['group'] === 'admin') {
$hasAccess = true;
} else if(in_array($user['_id'], $field['acl'] ?? [])) {
$hasAccess = true;
}
}
if (!$hasAccess) {
$new_entry[$field['name']] = $old_entry[$field['name']];
}
}

try {
$entry = $this->module('collections')->save($collection['name'], $entry, ['revision' => $revision]);
$new_entry = $this->module('collections')->save($collection['name'], $new_entry, ['revision' => $revision]);
} catch(\Throwable $e) {
$this->app->stop(['error' => $e->getMessage()], 412);
}

$this->app->helper('admin')->lockResourceId($entry['_id']);

return $entry;
return $new_entry;
}

public function delete_entries($collection) {
Expand All @@ -356,7 +376,7 @@ public function delete_entries($collection) {
$items = $this->module('collections')->find($collection['name'], ['filter' => $filter]);

if (count($items)) {

$trashItems = [];
$time = time();
$by = $this->module('cockpit')->getUser('_id');
Expand Down Expand Up @@ -478,7 +498,7 @@ public function find() {
$this->app->trigger("collections.admin.find.before.{$collection['name']}", [&$options]);
$entries = $this->app->module('collections')->find($collection['name'], $options);
$this->app->trigger("collections.admin.find.after.{$collection['name']}", [&$entries, $options]);

$count = $this->app->module('collections')->count($collection['name'], isset($options['filter']) ? $options['filter'] : []);
$pages = isset($options['limit']) ? ceil($count / $options['limit']) : 1;
$page = 1;
Expand Down
29 changes: 25 additions & 4 deletions modules/Collections/views/entries.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@

<script>

function CollectionHasFieldAccess(field) {

var acl = field.acl || [];

function _CollectionHasFieldAccess(field, acl) {
if (field.name == '_modified' ||
App.$data.user.group == 'admin' ||
!acl ||
Expand All @@ -22,6 +19,30 @@ function CollectionHasFieldAccess(field) {
return false;
}

function CollectionHasFieldAccess(field) {
var acl = [];
if (field.acl ) { acl = acl.concat(field.acl); }
if (field.acl_ro) { acl = acl.concat(field.acl_ro); }

return _CollectionHasFieldAccess(field, acl);
}

function CollectionHasFieldRwAccess(field) {
var acl_rw = field.acl || [];
var acl_ro = field.acl_ro || [];

// default to everyone having rw access when no acl present
if (!acl_rw.length && !acl_ro.length) { return true; }

if(App.$data.user.group == 'admin') { return true; }

// treat acl_rw as a whitelist when it has any values
if(acl_rw.length && _CollectionHasFieldAccess(field, acl_rw)){ return true; }

// treat acl_ro as a blacklist
return !this._CollectionHasFieldAccess(field, acl_ro);
}

</script>


Expand Down
52 changes: 42 additions & 10 deletions modules/Collections/views/entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<script type="riot/tag" src="@base('collections:assets/collection-linked.tag')?nc={{ $app['debug'] ? time() : $app['cockpit/version'] }}"></script>

<style>
@if(isset($collection['color']) && $collection['color'])
@if(isset($collection['color']) && $collection['color'])

.app-header {
border-top: 8px <?=$collection['color']?> solid;
Expand Down Expand Up @@ -95,6 +95,7 @@

<span class="uk-text-bold"><i class="uk-icon-pencil-square uk-margin-small-right"></i> { field.label || App.Utils.ucfirst(field.name) }</span>
<span class="uk-text-muted" show="{field.required}">&mdash; @lang('required')</span>
<span class="uk-text-muted" show="{!hasFieldRwAccess(field.name)}"> (@lang('Read Only'))</span>

<span if="{ field.localize }" data-uk-dropdown="mode:'click'">
<a class="uk-icon-globe" title="@lang('Localized field')" data-uk-tooltip="pos:'right'"></a>
Expand All @@ -110,7 +111,7 @@
</label>

<div class="uk-margin-top">
<cp-field type="{field.type || 'text'}" bind="entry.{ field.localize && parent.lang ? (field.name+'_'+parent.lang):field.name }" opts="{ field.options || {} }" required="{ field.required || false }"></cp-field>
<cp-field type="{field.type || 'text'}" bind="entry.{ field.localize && parent.lang ? (field.name+'_'+parent.lang):field.name }" opts="{ { ...(field.options || {}), disabled: !hasFieldRwAccess(field.name) } }" required="{ field.required || false }"></cp-field>
</div>

<div class="uk-margin-top uk-text-small uk-text-muted" if="{field.info}">
Expand Down Expand Up @@ -365,14 +366,7 @@
this.preview = true;
}

hasFieldAccess(field) {

var acl = this.fieldsidx[field] && this.fieldsidx[field].acl || [];

if (this.excludeFields.indexOf(field) > -1) {
return false;
}

_hasFieldAccess(field, acl) {
if (field == '_modified' ||
App.$data.user.group == 'admin' ||
!acl ||
Expand All @@ -386,6 +380,44 @@
return false;
}

hasFieldRwAccess(field) {
if (this.excludeFields.indexOf(field) > -1) {
return false;
}

var acl_rw = this.fieldsidx[field] && this.fieldsidx[field].acl || [];
var acl_ro = this.fieldsidx[field] && this.fieldsidx[field].acl_ro || [];

// default to everyone having rw access when no acl present
if (!acl_rw.length && !acl_ro.length) { return true; }

if(App.$data.user.group == 'admin') { return true; }

// treat acl_rw as a whitelist when it has any values
if(acl_rw.length && this._hasFieldAccess(field, acl_rw)){ return true; }

// treat acl_ro as a blacklist
return !this._hasFieldAccess(field, acl_ro);
}

hasFieldAccess(field) {
if (this.excludeFields.indexOf(field) > -1) {
return false;
}

let acl = [];
if(this.fieldsidx[field]) {
if(this.fieldsidx[field].acl) {
acl = acl.concat(this.fieldsidx[field].acl);
}
if(this.fieldsidx[field].acl_ro) {
acl = acl.concat(this.fieldsidx[field].acl_ro);
}
}

return this._hasFieldAccess(field, acl);
}

persistLanguage(e) {
App.session.set('collections.entry.'+this.collection._id+'.lang', e.target.value);
}
Expand Down
Loading