Fixes #2603 - Limit access to KB Categories based on roles.

This commit is contained in:
Mantas Masalskis 2022-02-24 12:15:19 +01:00 committed by Rolf Schmidt
parent 3a4ada93b4
commit 154b3accf9
75 changed files with 2123 additions and 187 deletions

View file

@ -11,6 +11,10 @@ class App.KnowledgeBaseAgentController extends App.Controller
super
@controllerBind('config_update_local', (data) => @configUpdated(data))
@listenTo App.User.current(), 'refresh', @visibilityMayHaveChanged
App.Event.bind 'kb_visibility_may_have_changed', @visibilityMayHaveChanged
if @permissionCheck('knowledge_base.*') and App.Config.get('kb_active')
@updateNavMenu()
else if App.Config.get('kb_active_publicly')
@ -51,15 +55,8 @@ class App.KnowledgeBaseAgentController extends App.Controller
@loadChange(pushed_data)
, 1000, key, 'kb_data_changed_loading')
)
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
return if !@displayingError
object = @constructor.pickObjectUsing(@lastParams, @)
if !@objectVisibleInternally(object)
return
@renderControllers(@lastParams)
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', @renderAfterChangeLoaded
@listenTo App.KnowledgeBase, 'kb_visibility_change_loaded', @renderAfterChangeLoaded
@checkForUpdates()
@ -112,6 +109,19 @@ class App.KnowledgeBaseAgentController extends App.Controller
notifyChangeLoaded: ->
App.KnowledgeBase.trigger('kb_data_change_loaded')
notifyVisibilityChangeLoaded: ->
App.KnowledgeBase.trigger('kb_visibility_change_loaded')
renderAfterChangeLoaded: =>
return if !@displayingError
object = @constructor.pickObjectUsing(@lastParams, @)
if !@objectVisibleInternally(object)
return
@renderControllers(@lastParams)
active: (state) ->
return @shown if state is undefined
@shown = state
@ -138,6 +148,10 @@ class App.KnowledgeBaseAgentController extends App.Controller
if !@permissionCheckRedirect("knowledge_base.#{@requiredPermissionSuffix(params)}")
return
if @loaded && @requiredPermissionSuffix(params) == 'editor' && @access(params) != 'editor'
@navigate params.match[0].replace(/\/edit$/, ''), { hideCurrentLocationFromHistory: true }
return
if @loaded && @rendered && @lastParams && !params.knowledge_base_id && @contentController && @kb_locale()?
@navigate @lastParams.match[0] , { hideCurrentLocationFromHistory: true }
return
@ -169,6 +183,7 @@ class App.KnowledgeBaseAgentController extends App.Controller
@pendingParams = params
renderScreenErrorInContent: (text) ->
@contentController?.releaseController()
@contentController = undefined
@renderScreenError(detail: text, el: @$('.page-content'))
@displayingError = true
@ -255,12 +270,12 @@ class App.KnowledgeBaseAgentController extends App.Controller
kb.kb_locales().filter((elem) => elem.system_locale_id == @lastParams.selectedSystemLocale.id)[0]
getKnowledgeBase: ->
App.KnowledgeBase.find(@lastParams.knowledge_base_id)
App.KnowledgeBase.find(@lastParams?.knowledge_base_id)
fetchAndRender: =>
@fetch(true, true)
fetch: (showLoader, processLoaded) ->
fetch: (showLoader, processLoaded, notifyVisibilityChangeLoaded) ->
if showLoader
@startLoading()
@ -278,6 +293,9 @@ class App.KnowledgeBaseAgentController extends App.Controller
if processLoaded
@processLoaded()
if notifyVisibilityChangeLoaded
@notifyVisibilityChangeLoaded()
,
error: (xhr) =>
if showLoader
@ -308,10 +326,11 @@ class App.KnowledgeBaseAgentController extends App.Controller
App[elem.modelName].find(id)?.remove(clear: true)
calculateIdsToDelete: (data) ->
Object
.keys(data)
.filter (elem) -> elem.match(/^KnowledgeBase/)
App.KnowledgeBase
.allKbModelNames()
.map (model) ->
return {ids : []} if !data[model]
newIds = Object.keys data[model]
oldIds = App[model].all().map (elem) -> elem.id
diff = oldIds.filter (elem) -> !_.includes(newIds, String(elem))
@ -339,8 +358,13 @@ class App.KnowledgeBaseAgentController extends App.Controller
parentController: @
)
access: (params) ->
@constructor
.pickObjectUsing(params, @)
?.access()
isEditor: ->
App.User.current().permission('knowledge_base.editor')
@access(@lastParams) == 'editor'
checkForUpdates: ->
@interval(@checkUpdatesAction, 10 * 60 * 1000, 'kb_interval_check')
@ -371,6 +395,27 @@ class App.KnowledgeBaseAgentController extends App.Controller
clicked: ->
window.open(App.KnowledgeBase.first().publicBaseUrl(), '_blank')
visibilityMayHaveChanged: =>
kb = @getKnowledgeBase()
return if !kb
@ajax
id: 'kb_pull_visibility'
type: 'GET'
url: @apiPath + '/knowledge_bases/visible_ids'
processData: true
success: (data, status, xhr) =>
didRemove = kb.removeAssetsIfNeeded(data)
hasAssetsToLoad = kb.hasAssetsToLoad(data)
if hasAssetsToLoad
@fetch(false, false, true)
return
if didRemove
@notifyVisibilityChangeLoaded()
@pickObjectUsing: (params, parentController) ->
kb = parentController.getKnowledgeBase()
return if !kb

View file

@ -103,7 +103,7 @@ class App.KnowledgeBaseContentCanBePublishedForm extends App.ControllerForm
,
value: 'internal'
name: __('Internal')
note: __('Visible to agents & editors')
note: __('Visible to readers & editors')
,
value: 'published'
name: __('Public')

View file

@ -15,6 +15,12 @@ class App.KnowledgeBaseEditorCoordinator
parentController: @parentController
)
clickedPermissions: (object) ->
new App.KnowledgeBasePermissionsDialog(
object: object
container: @parentController.el
)
# built-in Spine's function doesn't work when object has no ID set and includes "undefined" in URL
urlFor: (object) ->
if object.id

View file

@ -13,8 +13,8 @@ class App.KnowledgeBaseNavigation extends App.Controller
@controllerBind('knowledge_base::navigation::rerender', => @needsUpdate())
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@needsUpdate()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', @needsUpdate
@listenTo App.KnowledgeBase, 'kb_visibility_change_loaded', @needsUpdate
buildCrumbsForRendering: (array, kb_locale, action) ->
if action is 'search'
@ -73,7 +73,7 @@ class App.KnowledgeBaseNavigation extends App.Controller
else
false
needsUpdate: ->
needsUpdate: =>
@show(@savedParams, @savedAction)
selectedLocaleDisplay: ->

View file

@ -0,0 +1,92 @@
class App.KnowledgeBasePermissionsDialog extends App.ControllerModal
events:
#'submit form': 'submitPermissions'
'click td.u-clickable': 'cellBackgroundClicked'
head: 'Permissions'
includeForm: true
buttonSubmit: true
accessLevels: { editor: 'Editor', reader: 'Reader', none: 'None' }
cellBackgroundClicked: (e) ->
return if e.target != e.currentTarget
e.preventDefault()
e.currentTarget.querySelector('input')?.click()
data: null
constructor: (params) ->
super
@load()
content: =>
return if !@data
App.view('knowledge_base/permissions_dialog')(
accessLevels: @accessLevels
params: @loadedParams(@data)
roles: @formRoles(@data)
)
loadedParams: (data) ->
params = []
for permission in data.permissions
params[permission.role_id] = permission.access
for role in data.roles_editor
params[role.id] ||= 'editor'
for role in data.roles_reader
params[role.id] ||= 'reader'
params
formRoles: (data) ->
data.roles_editor.forEach (elem) => @formRolesItem(elem, 'editor', data)
data.roles_reader.forEach (elem) => @formRolesItem(elem, 'reader', data)
_.sortBy data.roles_editor.concat(data.roles_reader), (elem) -> elem.name
formRolesItem: (elem, role_name, data) ->
elem.accessLevel = role_name
elem.limit = _.findWhere(data.inherited, { role_id: elem.id })?.access
load: =>
@ajax(
id: 'knowledge_base_permissions_get'
type: 'get'
url: @object.generateURL('permissions')
processData: true
success: (data, status, xhr) =>
@data = data
@update()
error: (xhr) =>
@showAlert(xhr.responseJSON?.error || __('Unable to load changes'))
)
toggleDisabled: (state) =>
@el.find('input, button').attr('disabled', state)
onSubmit: (e) =>
@clearAlerts()
@toggleDisabled(true)
data = @formParams()
params = { permissions_dialog: { permissions: data } }
@ajax(
id: 'knowledge_base_permissions_patch'
type: 'PATCH'
data: JSON.stringify(params)
url: @object.generateURL('permissions')
processData: true
success: (data, status, xhr) =>
@close()
error: (xhr) =>
@toggleDisabled(false)
@showAlert(xhr.responseJSON?.error || __('Unable to save changes'))
)

View file

@ -54,7 +54,10 @@ class App.KnowledgeBaseReaderController extends App.Controller
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@renderAnswer(@object, kb_locale)
renderAnswer: (answer, kb_locale) ->
@listenTo App.KnowledgeBase, 'kb_visibility_change_loaded', =>
@renderAnswer(@object, kb_locale, true)
renderAnswer: (answer, kb_locale, onlyVisibility) ->
if !answer
@parentController.renderNotFound()
return
@ -63,13 +66,16 @@ class App.KnowledgeBaseReaderController extends App.Controller
@parentController.renderNotAvailableAnymore()
return
paginator = new App.KnowledgeBaseReaderPagination(object: @object, kb_locale: kb_locale)
@answerPagination.html paginator.el
if onlyVisibility
return
@renderAttachments(answer.attachments)
@renderTags(answer.tags)
@renderLinkedTickets(answer.translation(kb_locale.id)?.linked_tickets())
paginator = new App.KnowledgeBaseReaderPagination(object: @object, kb_locale: kb_locale)
@answerPagination.html paginator.el
answer_translation = answer.translation(kb_locale.id)
if !answer_translation

View file

@ -3,13 +3,13 @@ class App.KnowledgeBaseReaderListContainer extends App.Controller
super
@render()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@parentRefreshed()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', @parentRefreshed
@listenTo App.KnowledgeBase, 'kb_visibility_change_loaded', @parentRefreshed
tag: 'ul'
className: 'sections'
parentRefreshed: ->
parentRefreshed: =>
newIds = @children().map (elem) -> elem.id
oldIds = @el.children().toArray().map (elem) -> parseInt(elem.dataset.id)
@ -59,7 +59,9 @@ class App.KnowledgeBaseReaderListContainer.Categories extends App.KnowledgeBaseR
@parent.children()
else
[]
# coffeelint: enable=indentation
#
if !@isEditor
items = items.filter (elem) => elem.visibleInternally(@kb_locale)

View file

@ -3,9 +3,16 @@ class App.KnowledgeBaseReaderListController extends App.Controller
super
@render()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', @changeLoaded
@listenTo App.KnowledgeBase, 'kb_visibility_may_have_changed', => @changeLoaded(true)
changeLoaded: =>
if !@objectVisibleInternally()
@parentController.renderNotAvailableAnymore()
return
if @renderEmptinessState != @object.isEmpty()
@render()
elements:
'.js-readerListContainer': 'container'
@ -18,11 +25,13 @@ class App.KnowledgeBaseReaderListController extends App.Controller
@parentController.renderNotFound()
return
@renderEmptinessState = @object.isEmpty()
if @object.isEmpty()
@renderScreenPlaceholder(
icon: App.Utils.icon('mood-ok')
detail: __('This category is empty')
action: __('Start Editing')
action: if @parentController.isEditor() then __('Start Editing')
actionCallback: =>
url = @object.uiUrl(@parentController.kb_locale(), 'edit')
@navigate url

View file

@ -18,7 +18,10 @@ class App.KnowledgeBaseReaderListItem extends App.Controller
@sort_order = @item.position
try
attrs = @item.attributesForRendering(@kb_locale, isEditor: @isEditor)
catch e
attrs = {}
@el
.prop('className')

View file

@ -6,6 +6,7 @@ class App.KnowledgeBaseSidebar extends App.Controller
constructor: ->
super
@renderedWidgets = []
@show()
@controllerBind 'knowledge_base::sidebar::rerender', => @rerender()
@ -14,9 +15,9 @@ class App.KnowledgeBaseSidebar extends App.Controller
@rerender()
true
rerender: ->
rerender: =>
@delay( =>
@show(@savedParams, @savedAction)
@update()
, 300, 'rerender')
contentActionClicked: (e) ->
@ -24,10 +25,15 @@ class App.KnowledgeBaseSidebar extends App.Controller
actionName = switch e.target.dataset.action
when 'delete' then 'clickedDelete'
when 'visibility' then 'clickedCanBePublished'
when 'permissions' then 'clickedPermissions'
# coffeelint: enable=indentation
@parentController.bodyModal = @parentController.coordinator[actionName]?(@savedParams)
update: =>
for elem in @renderedWidgets
elem.updateIfNeeded?()
show: (object, action) ->
isEdit = action is 'edit'
@ -39,17 +45,22 @@ class App.KnowledgeBaseSidebar extends App.Controller
if !isEdit
return
for widget in @widgets(object)
@el.append new widget(
@renderedWidgets = []
for elem in @getWidgets(object)
widget = new elem(
object: object
kb_locale: @parentController.kb_locale()
parentController: @parentController
).el
)
@renderedWidgets.push widget
@el.append widget.el
hide: ->
@el.addClass('hidden')
widgets: (object) ->
getWidgets: (object) ->
output = [App.KnowledgeBaseSidebarActions]
if object instanceof App.KnowledgeBase || object instanceof App.KnowledgeBaseCategory

View file

@ -8,6 +8,9 @@ class App.KnowledgeBaseSidebarGenericList extends App.Controller
constructor: ->
super
@render()
render: ->
@html App.view('knowledge_base/sidebar/generic_list')(@templateOptions())
templateOptions: ->
@ -52,3 +55,6 @@ class App.KnowledgeBaseSidebarGenericList extends App.Controller
urlNew: ->
#has to be overridden
updateIfNeeded: ->
@render()

View file

@ -19,9 +19,9 @@ class App.KnowledgeBaseSidebarAttachments extends App.Controller
super
@render()
@listenTo @object, 'refresh', @needsUpdate
@listenTo @object, 'refresh', @updateIfNeeded
needsUpdate: =>
updateIfNeeded: =>
@render()
render: ->

View file

@ -12,9 +12,9 @@ class App.KnowledgeBaseSidebarLinkedTickets extends App.Controller
super
@render()
@listenTo @object, 'refresh', @needsUpdate
@listenTo @object, 'refresh', @updateIfNeeded
needsUpdate: =>
updateIfNeeded: =>
@render()
render: ->

View file

@ -11,3 +11,6 @@ class App.KnowledgeBaseSidebarTags extends App.Controller
object: @object
tags: @object.tags
)
updateIfNeeded: ->
@widget.reload(@object.tags)

View file

@ -0,0 +1,33 @@
InstanceMethods =
access: ->
permission_reader = App.Permission.findByAttribute('name', 'knowledge_base.reader')
permission_editor = App.Permission.findByAttribute('name', 'knowledge_base.editor')
permissions_effective = switch @constructor
when App.KnowledgeBaseAnswer
@category().permissions_effective
when App.KnowledgeBaseCategory, App.KnowledgeBase
@permissions_effective
access = 'none'
for role_id in App.User.current().role_ids
kb_permission = _.findWhere(permissions_effective, { role_id: role_id })
if kb_permission
switch kb_permission.access
when 'editor'
return 'editor'
when 'reader'
access = 'reader'
else if role = App.Role.find(role_id)
if role.permission_ids.indexOf(permission_editor.id) > -1
return 'editor'
if role.permission_ids.indexOf(permission_reader.id) > -1
access = 'reader'
return access
App.KnowledgeBaseAccess =
extended: ->
@include InstanceMethods

View file

@ -11,6 +11,15 @@ InstanceMethods =
disabled: @isNew()
}
if (@ instanceof App.KnowledgeBaseCategory) || (@ instanceof App.KnowledgeBase)
buttons.push {
iconName: 'lock'
name: 'Permissions'
action: 'permissions'
cssClass: 'btn--success'
disabled: @isNew()
}
if !(@ instanceof App.KnowledgeBase)
buttons.push {
iconName: 'trash'

View file

@ -34,12 +34,6 @@ InstanceMethods =
attrs.iconFont = true
attrs.icon = @category_icon
attrs.count = @countDeepAnswers()
if options.isEditor
attrs.editorOnly = !@visibleInternally(kb_locale)
else
attrs.editorOnly = false
attrs.state = @visibilityState(kb_locale)
if @ instanceof App.KnowledgeBaseAnswer
@ -47,16 +41,6 @@ InstanceMethods =
attrs.state = @can_be_published_state()
attrs.tags = @tags
if options.isEditor
attrs.editorOnly = !@is_internally_published(kb_locale)
else
attrs.editorOnly = false
# attrs.className = if attrs.missingTranslation
# 'kb-item--missing-translation'
# else if attrs.editorOnly
# 'kb-item--invisible'
attrs.icons = {}
if attrs.missingTranslation

View file

@ -2,6 +2,7 @@ class App.KnowledgeBase extends App.Model
@configure 'KnowledgeBase', 'iconset', 'color_highlight', 'color_header', 'color_header_link', 'translation_ids', 'locale_ids', 'homepage_layout', 'category_layout', 'custom_address'
@extend Spine.Model.Ajax
@extend App.KnowledgeBaseActions
@extend App.KnowledgeBaseAccess
@url: @apiPath + '/knowledge_bases'
@manageUrl: @apiPath + '/knowledge_bases/manage'
@ -76,7 +77,7 @@ class App.KnowledgeBase extends App.Model
, initial
visibleInternally: (kb_locale) ->
@active
@active && @access() != 'none'
visiblePublicly: (kb_locale) ->
@active
@ -88,6 +89,41 @@ class App.KnowledgeBase extends App.Model
attrs
loadedAnswerIds: ->
App.KnowledgeBaseAnswer
.all()
.filter (elem) => elem.knowledge_base().id == @id
.map (elem) -> elem.id
loadedCategoryIds: ->
App.KnowledgeBaseCategory
.all()
.map (elem) -> elem.id
removeAssetsIfNeeded: (data) =>
removeAnswers = _.difference @loadedAnswerIds(), data.answer_ids
removeCategories = _.difference @loadedAnswerIds(), data.category_ids
for answer_id in removeAnswers
App.KnowledgeBaseAnswer.find(answer_id)?.remove(clear: true)
for category_id in removeCategories
App.KnowledgeBaseCategory.find(category_id)?.remove(clear: true)
!_.isEmpty(removeAnswers) || !_.isEmpty(removeCategories)
hasAssetsToLoad: (data) =>
needsLoadingAnswers = _.difference data.answer_ids, @loadedAnswerIds()
needsLoadingCategories = _.difference data.category_ids, @loadedCategoryIds()
!_.isEmpty(needsLoadingAnswers) || !_.isEmpty(needsLoadingCategories)
@allKbModelNames: ->
Object
.keys(App)
.filter (elem) ->
elem.match(/^KnowledgeBase/) && App[elem]?.prototype instanceof App.Model
@configure_attributes: [
{
name: 'translation::title'

View file

@ -3,6 +3,7 @@ class App.KnowledgeBaseAnswer extends App.Model
@extend Spine.Model.Ajax
@extend App.KnowledgeBaseActions
@extend App.KnowledgeBaseCanBePublished
@extend App.KnowledgeBaseAccess
@serverClassName: 'KnowledgeBase::Answer'
@ -95,4 +96,4 @@ class App.KnowledgeBaseAnswer extends App.Model
'Answer'
visibleInternally: (kb_locale) =>
@is_internally_published(kb_locale)
(@is_internally_published(kb_locale) && @access() != 'none') || @is_published(kb_locale)

View file

@ -2,6 +2,7 @@ class App.KnowledgeBaseCategory extends App.Model
@configure 'KnowledgeBaseCategory', 'category_icon', 'parent_id', 'child_ids', 'translation_ids'
@extend Spine.Model.Ajax
@extend App.KnowledgeBaseActions
@extend App.KnowledgeBaseAccess
url: ->
@knowledge_base().generateURL('categories')
@ -166,6 +167,8 @@ class App.KnowledgeBaseCategory extends App.Model
'draft'
visibleInternally: (kb_locale) =>
#return false if @access() == 'none'
@findDeepAnswer( (record) ->
record.is_internally_published(kb_locale)
)?

View file

@ -0,0 +1,2 @@
class App.KnowledgeBaseCategoryPermission extends App.Model
@configure 'KnowledgeBaseCategoryPermission', 'access'

View file

@ -65,7 +65,6 @@ class App.KnowledgeBaseForm extends App.Controller
submit: (e) ->
@preventDefaultAndStopPropagation(e)
#debuggerj
formController = @formControllers.filter((elem) -> (elem.form[0] is e.currentTarget) or (e.currentTarget.contains(elem.form[0])))[0]
params = @formParam(formController.form)

View file

@ -0,0 +1,33 @@
<div style="padding-left: 18px; padding-top: 10px;">
<table class="settings-list settings-list--roles-permissions">
<thead>
<th width=150><%- @T('Role') %>
<% for key, text of @accessLevels: %>
<th><%- @T(text) %>
<% end %>
<tbody>
<% for role in @roles: %>
<tr>
<td>
<%= role.name %>
<% for key, text of @accessLevels: %>
<td class="settings-list-control-cell settings-list-radio-cell u-clickable">
<label class="inline-label radio-replacement">
<input
type="radio"
value="<%= key %>"
name="<%= role.id %>"
<% if @params[role.id] == key: %>checked<% end %>
<% if role.limit?: %>
<% if key == 'editor' && role.limit != 'editor': %>disabled<% end %>
<% if key == 'reader' && role.limit == 'none': %>disabled<% end %>
<% end %>
/>
<%- @Icon('radio', 'icon-unchecked') %>
<%- @Icon('radio-checked', 'icon-checked') %>
</label>
<% end %>
</tr>
<% end %>
</table>
</div>

View file

@ -10921,10 +10921,15 @@ output {
}
}
.settings-list-checkbox-cell {
.settings-list-checkbox-cell,
.settings-list-radio-cell {
vertical-align: middle;
padding-left: 8px;
}
.settings-list-radio-cell {
text-align: center;
}
}
.select-boxes {

View file

@ -0,0 +1,45 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase::PermissionsController < ApplicationController
prepend_before_action :authentication_check
before_action :fetch_object
def show
render json: response_hash
end
def update
permissions_params = params.require(:permissions_dialog).permit(permissions: {})
KnowledgeBase::PermissionsUpdate.new(@object, current_user).update_using_params!(permissions_params)
render json: response_hash
end
private
def fetch_object
if params[:knowledge_base_id]
@object = KnowledgeBase::Category.includes(:permissions).find params[:id]
authorize @object, :permissions?
else
@object = KnowledgeBase.includes(:permissions).find params[:id]
authorize @object, :update?
end
end
def parent_object
return if !@object.is_a? KnowledgeBase::Category
@object.parent || @object.knowledge_base
end
def response_hash
{
roles_reader: Role.with_permissions('knowledge_base.reader').pluck_as_hash(:id, :name),
roles_editor: Role.with_permissions('knowledge_base.editor').pluck_as_hash(:id, :name),
permissions: @object.permissions_effective.pluck_as_hash(:id, :access, :role_id),
inherited: parent_object&.permissions_effective&.pluck_as_hash(:id, :access, :role_id)
}
end
end

View file

@ -13,9 +13,7 @@ class KnowledgeBase::Public::AnswersController < KnowledgeBase::Public::BaseCont
private
def render_alternative
@alternative = policy_scope(@knowledge_base.answers)
.eager_load(translations: :kb_locale)
.find_by(id: params[:answer])
@alternative = find_answer @knowledge_base.answers.eager_load(translations: :kb_locale), params[:answer], locale: false
raise ActiveRecord::RecordNotFound if !@alternative&.translations&.any?

View file

@ -2,7 +2,8 @@
class KnowledgeBase::Public::BaseController < ApplicationController
before_action :load_kb
helper_method :system_locale_via_uri, :fallback_locale, :current_user, :find_category, :filter_primary_kb_locale, :menu_items, :all_locales
helper_method :system_locale_via_uri, :fallback_locale, :current_user, :find_category,
:filter_primary_kb_locale, :menu_items, :all_locales, :can_preview?
layout 'knowledge_base'
@ -31,12 +32,9 @@ class KnowledgeBase::Public::BaseController < ApplicationController
end
def fallback_locale
if all_locales.find { |locale| locale.id == system_locale_via_uri&.id }
system_locale_via_uri
else
filter_primary_kb_locale || all_locales.first
end
return system_locale_via_uri if all_locales.find { |locale| locale.id == system_locale_via_uri&.id }
filter_primary_kb_locale || all_locales.first
end
def filter_primary_kb_locale
@ -62,16 +60,40 @@ class KnowledgeBase::Public::BaseController < ApplicationController
list
.localed(system_locale_via_uri)
.sorted
.select { |category| policy(category).show? }
.select { |category| policy(category).show_public? }
end
def find_answer(scope, id)
def answers_filter(list)
answers = list
.localed(system_locale_via_uri)
.sorted
if current_user&.permissions?('knowledge_base.editor')
answers.filter { |answer| policy(answer).show_public? }
else
answers.published
end
end
def find_answer(scope, id, locale: system_locale_via_uri)
return if scope.nil?
policy_scope(scope)
.localed(system_locale_via_uri)
.include_contents
.find_by(id: id)
scope = scope.include_contents
scope = scope.localed(locale) if locale
if !current_user&.permissions?('knowledge_base.editor')
return scope.published.find_by(id: id)
end
if (item = scope.find_by(id: id)) && policy(item).show_public?
return item
end
nil
end
def can_preview?
@can_preview ||= policy(@knowledge_base).update?
end
def not_found(e)

View file

@ -15,14 +15,11 @@ class KnowledgeBase::Public::CategoriesController < KnowledgeBase::Public::BaseC
def show
@object = find_category(params[:category])
render_alternatives && return if @object.nil? || !policy(@object).show?
render_alternatives && return if @object.nil? || !policy(@object).show_public?
@categories = categories_filter(@object.children)
@object_locales = find_locales(@object)
@answers = policy_scope(@object.answers)
.localed(system_locale_via_uri)
.sorted
@answers = answers_filter(@object.answers)
render :index
end

View file

@ -3,11 +3,6 @@
class KnowledgeBase::Public::TagsController < KnowledgeBase::Public::BaseController
def show
@object = [:tag, params[:tag]]
all_tagged = KnowledgeBase::Answer.tag_objects(params[:tag])
@answers = policy_scope(all_tagged)
.localed(system_locale_via_uri)
.sorted
@answers = answers_filter KnowledgeBase::Answer.tag_objects(params[:tag])
end
end

View file

@ -5,15 +5,41 @@ class KnowledgeBasesController < KnowledgeBase::BaseController
render json: assets(params[:answer_translation_content_ids])
end
def visible_ids
render json: KnowledgeBase::InternalAssets.new(current_user).visible_ids
end
private
def assets(answer_translation_content_ids = nil)
return editor_assets(answer_translation_content_ids) if current_user&.permissions?('knowledge_base.editor')
return reader_assets(answer_translation_content_ids) if current_user&.permissions?('knowledge_base.reader')
if KnowledgeBase.granular_permissions?
return granular_assets(answer_translation_content_ids) if kb_permissions?
else
return editor_assets(answer_translation_content_ids) if kb_permission_editor?
return reader_assets(answer_translation_content_ids) if kb_permission_reader?
end
public_assets
end
def kb_permissions?
current_user&.permissions?(%w[knowledge_base.editor knowledge_base.reader])
end
def kb_permission_editor?
current_user&.permissions?('knowledge_base.editor')
end
def kb_permission_reader?
current_user&.permissions?('knowledge_base.reader')
end
def granular_assets(answer_translation_content_ids)
KnowledgeBase::InternalAssets
.new(current_user, answer_translation_content_ids: answer_translation_content_ids)
.collect_assets
end
def editor_assets(answer_translation_content_ids)
assets = [
KnowledgeBase,

View file

@ -36,7 +36,7 @@ module KnowledgeBaseTopBarHelper
end
def render_top_bar_if_needed(object, knowledge_base)
return if !policy(:knowledge_base).edit?
return if !can_preview?
editable = object || knowledge_base

View file

@ -2,7 +2,7 @@
module KnowledgeBaseVisibilityClassHelper
def visibility_class_name(object)
return if !policy(:knowledge_base).edit?
return if !can_preview?
suffix = case object
when CanBePublished

View file

@ -2,7 +2,7 @@
module KnowledgeBaseVisibilityNoteHelper
def visibility_note(object)
return if !policy(:knowledge_base).edit?
return if !can_preview?
text = visibility_text(object)

View file

@ -4,12 +4,12 @@ class ChecksKbClientNotificationJob < ApplicationJob
include HasActiveJobLock
def lock_key
# "ChecksKbClientNotificationJob/KnowledgeBase::Answer/42/destroy"
"#{self.class.name}/#{arguments[0]}/#{arguments[1]}/#{arguments[2]}"
# "ChecksKbClientNotificationJob/KnowledgeBase::Answer/42"
"#{self.class.name}/#{arguments[0]}/#{arguments[1]}"
end
def perform(klass_name, id, event)
object = klass_name.constantize.find_by(id: id)
def perform(klass_name, object_id)
object = klass_name.constantize.find_by(id: object_id)
return if object.blank?
level = needs_editor?(object) ? 'editor' : '*'
@ -24,7 +24,7 @@ class ChecksKbClientNotificationJob < ApplicationJob
def build_data(object, event)
timestamp = event == :destroy ? Time.zone.now : object.updated_at
url = event == :destroy ? nil : object.api_url
url = event == :destroy ? nil : object.try(:api_url)
{
class: object.class.to_s,
@ -57,8 +57,4 @@ class ChecksKbClientNotificationJob < ApplicationJob
.filter_map { |user_id| User.find_by(id: user_id) }
.select { |user| user.permissions? "knowledge_base.#{permission_suffix}" }
end
def self.notify_later(object, event)
perform_later(object.class.to_s, object.id, event.to_s)
end
end

View file

@ -0,0 +1,14 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class ChecksKbClientVisibilityJob < ApplicationJob
include HasActiveJobLock
def lock_key
# "ChecksKbClientVisibilityJob"
self.class.name
end
def perform
Sessions.broadcast({ event: 'kb_visibility_may_have_changed' })
end
end

View file

@ -4,10 +4,7 @@ module ChecksKbClientNotification
extend ActiveSupport::Concern
included do
after_create :notify_kb_clients_after_create
after_update :notify_kb_clients_after_update
after_touch :notify_kb_clients_after_touch
after_destroy :notify_kb_clients_after_destroy
after_commit :notify_kb_clients_after
class_attribute :notify_kb_clients_suspend, default: false
end
@ -30,25 +27,9 @@ module ChecksKbClientNotification
# generic call
def notify_kb_clients(event)
def notify_kb_clients_after
return if self.class.notify_kb_clients_suspend?
ChecksKbClientNotificationJob.notify_later(self, event)
end
def notify_kb_clients_after_create
notify_kb_clients(:create)
end
def notify_kb_clients_after_update
notify_kb_clients(:update)
end
def notify_kb_clients_after_touch
notify_kb_clients(:touch)
end
def notify_kb_clients_after_destroy
notify_kb_clients(:destroy)
ChecksKbClientNotificationJob.perform_later(self.class.name, id)
end
end

View file

@ -0,0 +1,17 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
module ChecksKbClientVisibility
extend ActiveSupport::Concern
included do
after_commit :notify_kb_client_visibility
end
private
def notify_kb_client_visibility
return if self.class.notify_kb_clients_suspend?
ChecksKbClientVisibilityJob.perform_later
end
end

View file

@ -24,6 +24,11 @@ class KnowledgeBase < ApplicationModel
has_many :answers, through: :categories
has_many :permissions, class_name: 'KnowledgeBase::Permission',
as: :permissionable,
autosave: true,
dependent: :destroy
validates :category_layout, inclusion: { in: KnowledgeBase::LAYOUTS }
validates :homepage_layout, inclusion: { in: KnowledgeBase::LAYOUTS }
@ -156,6 +161,24 @@ class KnowledgeBase < ApplicationModel
.any? { |e| e > 1 }
end
def permissions_effective
cache_key = KnowledgeBase::Permission.cache_key self
Rails.cache.fetch cache_key do
permissions
end
end
def attributes_with_association_ids
attrs = super
attrs[:permissions_effective] = permissions_effective
attrs
end
def self.granular_permissions?
KnowledgeBase::Permission.any?
end
private
def set_defaults

View file

@ -6,6 +6,7 @@ class KnowledgeBase::Answer < ApplicationModel
include HasTags
include CanBePublished
include ChecksKbClientNotification
include ChecksKbClientVisibility
include CanCloneAttachments
AGENT_ALLOWED_ATTRIBUTES = %i[category_id promoted internal_note].freeze
@ -49,7 +50,13 @@ class KnowledgeBase::Answer < ApplicationModel
siblings = category.answers
if !User.lookup(id: UserInfo.current_user_id)&.permissions?('knowledge_base.editor')
siblings = siblings.internal
ep = KnowledgeBase::EffectivePermission.new User.find(UserInfo.current_user_id), category
siblings = if ep.access_effective == 'public_reader'
siblings.published
else
siblings.internal
end
end
data = ApplicationModel::CanAssets.reduce(siblings, data)

View file

@ -4,6 +4,7 @@ class KnowledgeBase::Category < ApplicationModel
include HasTranslations
include HasAgentAllowedParams
include ChecksKbClientNotification
include ChecksKbClientVisibility
AGENT_ALLOWED_ATTRIBUTES = %i[knowledge_base_id parent_id category_icon].freeze
AGENT_ALLOWED_NESTED_RELATIONS = %i[translations].freeze
@ -24,6 +25,11 @@ class KnowledgeBase::Category < ApplicationModel
touch: true,
optional: true
has_many :permissions, class_name: 'KnowledgeBase::Permission',
as: :permissionable,
autosave: true,
dependent: :destroy
validates :category_icon, presence: true
scope :root, -> { where(parent: nil) }
@ -119,6 +125,20 @@ class KnowledgeBase::Category < ApplicationModel
Rails.application.routes.url_helpers.knowledge_base_category_path(knowledge_base, self)
end
def permissions_effective
cache_key = KnowledgeBase::Permission.cache_key self
Rails.cache.fetch cache_key do
KnowledgeBase::Category::Permission.new(self).permissions_effective
end
end
def attributes_with_association_ids
attrs = super
attrs[:permissions_effective] = permissions_effective
attrs
end
private
def cannot_be_child_of_parent

View file

@ -0,0 +1,16 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase::Permission < ApplicationModel
belongs_to :permissionable, polymorphic: true, touch: true
belongs_to :role
validates :access, inclusion: { in: %w[editor reader none] }
validates :role, uniqueness: { scope: %i[permissionable_id permissionable_type] }
# cache key for calculated permissions
# @param permissionable [KnowledgeBase::Category, KnowledgeBase]
# @return [String]
def self.cache_key(permissionable)
"#{permissionable.class}::aws::#{permissionable.id}::permission::#{permissionable.updated_at}"
end
end

View file

@ -14,11 +14,12 @@ class Role < ApplicationModel
has_and_belongs_to_many :users, after_add: :cache_update, after_remove: :cache_update
has_and_belongs_to_many :permissions,
before_add: %i[validate_agent_limit_by_permission validate_permissions],
after_add: :cache_update,
after_add: %i[cache_update cache_add_kb_permission],
before_remove: :last_admin_check_by_permission,
after_remove: :cache_update
after_remove: %i[cache_update cache_remove_kb_permission]
validates :name, presence: true
store :preferences
has_many :knowledge_base_permissions, class_name: 'KnowledgeBase::Permission', dependent: :destroy
before_create :check_default_at_signup_permissions
before_update :last_admin_check_by_attribute, :validate_agent_limit_by_attributes, :check_default_at_signup_permissions
@ -232,4 +233,31 @@ returns
raise Exceptions::UnprocessableEntity, "Cannot set default at signup when role has #{forbidden_permissions.join(', ')} permissions."
end
def cache_add_kb_permission(permission)
return if !permission.name.starts_with? 'knowledge_base.'
return if !KnowledgeBase.granular_permissions?
KnowledgeBase::Category.all.each(&:touch)
end
def cache_remove_kb_permission(permission)
return if !permission.name.starts_with? 'knowledge_base.'
return if !KnowledgeBase.granular_permissions?
has_editor = permissions.where(name: 'knowledge_base.editor').any?
has_reader = permissions.where(name: 'knowledge_base.reader').any?
KnowledgeBase::Permission
.where(role: self)
.each do |elem|
if !has_editor && !has_reader
elem.destroy!
elsif !has_editor && has_reader
elem.update!(access: 'reader') if permission.access == 'editor'
end
end
KnowledgeBase::Category.all.each(&:touch)
end
end

View file

@ -2,9 +2,34 @@
class Controllers::KnowledgeBase::AnswersControllerPolicy < Controllers::KnowledgeBase::BaseControllerPolicy
def show?
return true if user.permissions?('knowledge_base.editor')
access(__method__)
end
object = record.klass.find(record.params[:id])
object.can_be_published_aasm.internal? || object.can_be_published_aasm.published?
def create?
verify_category(__method__)
end
def update?
access(__method__) && verify_category(__method__)
end
def destroy?
access(__method__)
end
private
def object
@object ||= record.klass.find(record.params[:id])
end
def access(method)
KnowledgeBase::AnswerPolicy.new(user, object).send(method)
end
def verify_category(method)
new_category = KnowledgeBase::Category.find(record.params[:category_id])
KnowledgeBase::CategoryPolicy.new(user, new_category).send(method)
end
end

View file

@ -2,8 +2,38 @@
class Controllers::KnowledgeBase::CategoriesControllerPolicy < Controllers::KnowledgeBase::BaseControllerPolicy
def show?
return if user.permissions?('knowledge_base.editor')
access(__method__)
end
record.klass.find(record.params[:id]).internal_content?
def create?
verify_parent(__method__)
end
def update?
access(__method__) && verify_parent(__method__)
end
def destroy?
access(__method__)
end
private
def object
@object ||= record.klass.find(record.params[:id])
end
def access(method)
KnowledgeBase::CategoryPolicy.new(user, object).send(method)
end
def verify_parent(method)
if record.params[:parent_id].blank?
return user.permissions?('knowledge_base.editor')
end
parent = KnowledgeBase::Category.find(record.params[:parent_id])
KnowledgeBase::CategoryPolicy.new(user, parent).send(method)
end
end

View file

@ -12,4 +12,18 @@ class Controllers::KnowledgeBasesControllerPolicy < Controllers::KnowledgeBase::
def destroy?
false
end
def update?
access(__method__)
end
private
def object
@object ||= record.klass.find(record.params[:id])
end
def access(method)
KnowledgeBase::CategoryPolicy.new(user, object).send(method)
end
end

View file

@ -2,13 +2,39 @@
class KnowledgeBase::AnswerPolicy < ApplicationPolicy
def show?
return true if user&.permissions?(%w[knowledge_base.editor])
return true if access_editor?
record.visible? ||
(user&.permissions?(%w[knowledge_base.reader]) && record.visible_internally?)
(access_reader? && record.visible_internally?)
end
def show_public?
access_editor? || record.visible?
end
def create?
access_editor?
end
def update?
access_editor?
end
def destroy?
user&.permissions?(%w[knowledge_base.editor])
access_editor?
end
private
def access
@access ||= KnowledgeBase::EffectivePermission.new(user, record.category).access_effective
end
def access_editor?
access == 'editor'
end
def access_reader?
access == 'reader'
end
end

View file

@ -2,13 +2,51 @@
class KnowledgeBase::CategoryPolicy < ApplicationPolicy
def show?
return true if user&.permissions?('knowledge_base.editor')
access_editor? || access_reader?
end
record.public_content?
def show_public?
access_editor? || record.public_content?
end
def permissions?
access_editor?
end
def create?
parent_editor?
end
def update?
access_editor?
end
def destroy?
parent_editor?
end
private
def access
@access ||= KnowledgeBase::EffectivePermission.new(user, record).access_effective
end
def access_editor?
access == 'editor'
end
def access_reader?
access == 'reader'
end
def parent_access
@parent_access ||= KnowledgeBase::EffectivePermission.new(user, (record.parent || record.knowledge_base)).access_effective
end
def parent_editor?
parent_access == 'editor'
end
def user_required?
false
end

View file

@ -1,7 +1,25 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBasePolicy < ApplicationPolicy
def edit?
user&.permissions?('knowledge_base.editor')
def show?
access_editor? || access_reader?
end
def update?
access_editor?
end
private
def access
@access ||= KnowledgeBase::EffectivePermission.new(user, record).access_effective
end
def access_editor?
access == 'editor'
end
def access_reader?
access == 'reader'
end
end

View file

@ -20,6 +20,7 @@ Zammad::Application.routes.draw do
resources :knowledge_bases, only: %i[show update] do
collection do
post :init
get :visible_ids
post :search, controller: 'knowledge_base/search'
get :recent_answers, controller: 'knowledge_base/answers'
@ -35,11 +36,17 @@ Zammad::Application.routes.draw do
end
end
member do
resource :permissions, controller: 'knowledge_base/permissions', only: %i[update show]
end
resources :categories, controller: 'knowledge_base/categories',
except: %i[new edit] do
member do
patch :reorder_categories, :reorder_answers
resource :permissions, controller: 'knowledge_base/permissions', only: %i[update show]
end
collection do

View file

@ -108,6 +108,18 @@ class InitializeKnowledgeBase < ActiveRecord::Migration[5.0]
t.timestamps # rubocop:disable Zammad/ExistsDateTimePrecision
end
create_table :knowledge_base_permissions do |t|
t.references :permissionable, polymorphic: true, null: false, index: { name: 'index_knowledge_base_permissions_on_permissionable' }
t.references :role, null: false, foreign_key: { to_table: :roles }
t.string 'access', limit: 50, default: 'full', null: false
t.index 'access'
t.index %i[role_id permissionable_id permissionable_type], unique: true, name: 'knowledge_base_permissions_uniqueness'
t.timestamps limit: 3
end
Setting.create_if_not_exists(
title: 'Kb multi-lingual support',
name: 'kb_multi_lingual_support',
@ -173,7 +185,8 @@ class InitializeKnowledgeBase < ActiveRecord::Migration[5.0]
note: 'Access %s',
preferences: {
translations: ['Knowledge Base']
}
},
allow_signup: true,
)
Permission.create_if_not_exists(

View file

@ -0,0 +1,22 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
# Using older 5.0 migration to stick to Integer primary keys. Otherwise migration fails in MySQL.
class CreateKnowledgeBasePermissions < ActiveRecord::Migration[5.0]
def change
return if !Setting.exists?(name: 'system_init_done')
create_table :knowledge_base_permissions do |t|
t.references :permissionable, polymorphic: true, null: false, index: { name: 'index_knowledge_base_permissions_on_permissionable' }
t.references :role, null: false, foreign_key: { to_table: :roles }
t.string 'access', limit: 50, default: 'full', null: false
t.index 'access'
t.index %i[role_id permissionable_id permissionable_type], unique: true, name: 'knowledge_base_permissions_uniqueness'
t.timestamps limit: 3
end
Permission.where(name: 'knowledge_base.reader').update_all(allow_signup: true) # rubocop:disable Rails/SkipsModelValidations
end
end

View file

@ -445,7 +445,8 @@ Permission.create_if_not_exists(
note: __('Manage %s'),
preferences: {
translations: [__('Knowledge Base Reader')]
}
},
allow_signup: true,
)
admin = Role.find_by(name: 'Admin')

View file

@ -5163,6 +5163,10 @@ msgstr ""
msgid "Invalid payload, need data:image in logo param"
msgstr ""
#: lib/knowledge_base/permissions_update.rb
msgid "Invalid permissions, do not lock out yourself"
msgstr ""
#: app/models/concerns/checks_condition_validation.rb
msgid "Invalid ticket selector conditions"
msgstr ""
@ -7613,6 +7617,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
#: app/assets/javascripts/app/controllers/role.coffee
#: app/assets/javascripts/app/views/integration/ldap.jst.eco
#: app/assets/javascripts/app/views/knowledge_base/permissions_dialog.jst.eco
msgid "Role"
msgstr ""
@ -9570,6 +9575,14 @@ msgstr ""
msgid "URL (AJAX endpoint)"
msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/permissions_dialog.coffee
msgid "Unable to load changes"
msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/permissions_dialog.coffee
msgid "Unable to save changes"
msgstr ""
#: app/controllers/first_steps_controller.rb
#: db/seeds/overviews.rb
msgid "Unassigned & Open Tickets"
@ -10024,11 +10037,11 @@ msgid "Visibility"
msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee
msgid "Visible to agents & editors"
msgid "Visible to everyone"
msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee
msgid "Visible to everyone"
msgid "Visible to readers & editors"
msgstr ""
#: app/assets/javascripts/app/controllers/_integration/placetel.coffee

View file

@ -37,3 +37,24 @@ module ActiveRecord
end
end
end
module Enumerable
def pluck_as_hash(*column_names)
column_names.flatten! # flatten args in case array was given
pluck(*column_names)
.map { |elem| pluck_as_hash_map(column_names, elem) }
end
private
def pluck_as_hash_map(keys, values)
if keys.one?
{
keys.first => values
}
else
keys.zip(values).to_h
end
end
end

View file

@ -0,0 +1,33 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase
class Category
class Permission
def initialize(category)
@category = category
end
def permissions_effective
parents_for_category
.map(&:permissions)
.flatten
.each_with_object([]) do |elem, memo|
memo << elem if !memo.find { |added| added.role == elem.role }
end
end
private
def parents_for_category
categories_tree = @category.self_with_parents
categories_with_permissions = KnowledgeBase::Category.where(id: categories_tree).includes(:permissions).to_a
sorted_with_permissions = categories_tree
.map { |elem| categories_with_permissions.find { |elem_with_permissions| elem_with_permissions == elem } }
sorted_with_permissions + [@category.knowledge_base]
end
end
end
end

View file

@ -0,0 +1,58 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase
class EffectivePermission
def initialize(user, object)
@user = user
@object = object
end
def access_effective
return 'none' if !@user
@user.roles.reduce('none') do |memo, role|
access = access_role_effective(role)
return access if access == 'editor'
memo == 'reader' ? memo : access
end
end
private
def permissions
@permissions ||= @object.permissions_effective
end
def access_role_effective(role)
permission = permissions.find { |elem| elem.role == role }
return default_role_access(role) if !permission
calculate_role(role, permission)
end
def calculate_role(role, permission)
if permission.access == 'editor' && role.with_permission?('knowledge_base.editor')
'editor'
elsif %w[editor reader].include?(permission.access) && role.with_permission?(%w[knowledge_base.editor knowledge_base.reader])
'reader'
elsif @object.public_content?
'public_reader'
else
'none'
end
end
def default_role_access(role)
if role.with_permission?('knowledge_base.editor')
'editor'
elsif role.with_permission?('knowledge_base.reader')
'reader'
else
'none'
end
end
end
end

View file

@ -0,0 +1,120 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase
class InternalAssets
CategoriesCache = Struct.new(:editor, :reader, :public_reader, keyword_init: true) do
def all
editor + reader + public_reader
end
end
attr_reader :assets
def initialize(user, answer_translation_content_ids: [])
@user = user
@assets = {}
@answer_translation_content_ids = answer_translation_content_ids
end
def collect_assets
collect_base_assets
add_to_assets accessible_categories.all, type: :essential
add_to_assets KnowledgeBase::Category::Translation.where(category: accessible_categories.all)
collect_all_answer_assets
@assets
end
def accessible_categories
@accessible_categories ||= accessible_categories_calculate
end
def all_answer_ids
all_answer_batches.each_with_object([]) do |elem, sum|
sum.concat elem.pluck(:id)
end
end
def all_category_ids
accessible_categories.all.pluck(:id)
end
def visible_ids
{
answer_ids: all_answer_ids,
category_ids: all_category_ids
}
end
private
def accessible_categories_calculate
struct = CategoriesCache.new editor: [], reader: [], public_reader: []
KnowledgeBase::Category.all.find_in_batches do |group|
group.each do |cat|
case KnowledgeBase::EffectivePermission.new(@user, cat).access_effective
when 'editor'
struct.editor << cat
when 'reader'
struct.reader << cat if cat.internal_content?
when 'public_reader'
struct.public_reader << cat if cat.public_content?
end
end
end
struct
end
def add_to_assets(objects, type: nil)
@assets = ApplicationModel::CanAssets.reduce(objects, @assets, type)
end
def collect_base_assets
[KnowledgeBase, KnowledgeBase::Translation, KnowledgeBase::Locale]
.each do |klass|
klass.find_in_batches do |group|
add_to_assets group, type: :essential
end
end
end
def all_answer_batches
[
KnowledgeBase::Answer.where(category: accessible_categories.editor),
KnowledgeBase::Answer.internal.where(category: accessible_categories.reader),
KnowledgeBase::Answer.published.where(category: accessible_categories.public_reader)
]
end
def collect_all_answer_assets
all_answer_batches.each do |batch|
collect_answers_assets batch
end
end
def collect_answers_assets(scope)
scope.find_in_batches do |group|
add_to_assets group, type: :essential
translations = KnowledgeBase::Answer::Translation.where(answer: group)
add_to_assets translations, type: :essential
if @answer_translation_content_ids.present?
contents = KnowledgeBase::Answer::Translation::Content
.joins(:translation)
.where(
id: @answer_translation_content_ids,
knowledge_base_answer_translations: { answer_id: group }
)
add_to_assets contents
end
end
end
end
end

View file

@ -0,0 +1,97 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase
class PermissionsUpdate
def initialize(object, user = nil)
@object = object
@user = user
end
def update!(**roles_to_permissions)
ActiveRecord::Base.transaction do
update_object(roles_to_permissions)
next if !@object.changed_for_autosave?
@object.save!
update_all_children
ensure_editable!
end
end
def update_using_params!(params)
roles_to_permissions = params[:permissions].transform_keys { |key| Role.find key }
update!(**roles_to_permissions)
end
private
def update_object(roles_to_permissions)
@object.permissions.reject { |elem| roles_to_permissions.key? elem.role }.each(&:mark_for_destruction)
roles_to_permissions.each do |role, access|
update_object_permission(role, access)
end
end
def update_object_permission(role, access)
permission = @object.permissions.detect { |elem| elem.role == role } || @object.permissions.build(role: role)
permission.access = access
mark_permission_for_cleanup_if_needed(permission, parent_object_permissions)
end
def parent_object_permissions
@parent_object_permissions ||= begin
if @object.is_a? KnowledgeBase::Category
(@object.parent || @object.knowledge_base).permissions_effective || []
else
[]
end
end
end
def all_children
case @object
when KnowledgeBase::Category
@object.self_with_children - [@object]
when KnowledgeBase
@object.categories.root.map(&:self_with_children).flatten
end
end
def update_single_child(child)
inherited_permissions = child.permissions_effective
child.permissions.each do |child_permission|
mark_permission_for_cleanup_if_needed(child_permission, inherited_permissions)
end
child.changed_for_autosave? ? child.save! : child.touch # rubocop:disable Rails/SkipsModelValidations
end
def update_all_children
all_children.each do |child|
update_single_child(child)
end
end
def ensure_editable!
return if !@user
return if KnowledgeBase::EffectivePermission.new(@user, @object).access_effective == 'editor'
raise Exceptions::UnprocessableEntity, __('Invalid permissions, do not lock out yourself')
end
def mark_permission_for_cleanup_if_needed(permission, parents)
matching = parents.find { |elem| elem.role == permission.role }
return if !matching
return if matching.access != permission.access && matching.access != 'none'
permission.mark_for_destruction
end
end
end

View file

@ -0,0 +1,9 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
FactoryBot.define do
factory 'knowledge_base/permission', aliases: %i[knowledge_base_permission] do
permissionable { create(:knowledge_base_category) }
role { create(:role) }
access { 'editor' }
end
end

View file

@ -6,6 +6,12 @@ FactoryBot.define do
created_by_id { 1 }
updated_by_id { 1 }
transient do
permission_names { nil }
end
permissions { Permission.where(name: permission_names) }
factory :agent_role do
permissions { Permission.where(name: 'ticket.agent') }
end

View file

@ -0,0 +1,73 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe KnowledgeBase::Category::Permission do
include_context 'basic Knowledge Base'
describe '#permissions_effective' do
let(:child_category) { create(:knowledge_base_category, knowledge_base: knowledge_base, parent: category) }
let(:child_permission) { create(:knowledge_base_permission, permissionable: child_category, access: 'reader') }
let(:parent_permission) { create(:knowledge_base_permission, permissionable: category) }
context 'when no permissions exist' do
it 'parent category returns nil' do
expect(described_class.new(category).permissions_effective).to be_blank
end
it 'child category returns nil' do
expect(described_class.new(child_category).permissions_effective).to be_blank
end
end
context 'when parent category has permissions' do
before { parent_permission && child_category }
it 'parent category returns parent permission' do
expect(described_class.new(category).permissions_effective).to eq [parent_permission]
end
it 'child category returns parent permission' do
expect(described_class.new(child_category).permissions_effective).to eq [parent_permission]
end
end
context 'when child category has permissions' do
before { child_permission }
it 'parent category returns parent permission' do
expect(described_class.new(category).permissions_effective).to be_blank
end
it 'child category returns parent permission' do
expect(described_class.new(child_category).permissions_effective).to eq [child_permission]
end
end
context 'when both parent and child categories have permissions' do
before { child_permission && parent_permission }
it 'parent category returns parent permission' do
expect(described_class.new(category).permissions_effective).to eq [parent_permission]
end
it 'child category returns parent permission' do
expect(described_class.new(child_category).permissions_effective).to eq [child_permission, parent_permission]
end
end
context 'when both parent child categories have colliding permissions for the same role' do
let(:child_permission_on_same_role) { create(:knowledge_base_permission, permissionable: child_category, access: 'reader', role: parent_permission.role) }
before { parent_permission && child_permission && child_permission_on_same_role }
it 'parent category returns parent permission' do
expect(described_class.new(category).permissions_effective).to eq [parent_permission]
end
it 'child category returns child permission override' do
expect(described_class.new(child_category).permissions_effective).to eq [child_permission, child_permission_on_same_role]
end
end
end
end

View file

@ -0,0 +1,91 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe KnowledgeBase::EffectivePermission do
include_context 'basic Knowledge Base'
describe '#access_effective' do
let(:role_editor) { create(:role, permission_names: 'knowledge_base.editor') }
let(:role_reader) { create(:role, permission_names: 'knowledge_base.reader') }
let(:role_non_kb) { create(:role, :admin) }
let(:user) { create(:user, roles: [role_editor, role_reader, role_non_kb]) }
let(:user_editor) { create(:user, roles: [role_editor]) }
let(:user_admin) { create(:admin) }
let(:user_reader) { create(:user, roles: [role_reader]) }
let(:user_nonkb) { create(:user, roles: [role_non_kb]) }
let(:child_category) { create(:knowledge_base_category, parent: category) }
it 'editor with no permissions defined returns editor' do
expect(described_class.new(user_editor, category).access_effective).to eq 'editor'
end
it 'user with multiple permissions defined returns editor' do
expect(described_class.new(user, category).access_effective).to eq 'editor'
end
it 'reader with no permissions defined returns reader' do
expect(described_class.new(user_reader, category).access_effective).to eq 'reader'
end
it 'non-kb with no permissions defined returns none' do
expect(described_class.new(user_nonkb, category).access_effective).to eq 'none'
end
it 'editor with both reader and editor permissions returns editor' do
create_permission(role_reader, 'reader')
create_permission(role_editor, 'editor')
expect(described_class.new(user_admin, category).access_effective).to eq 'editor'
end
it 'editor with reader permission on parent category returns reader' do
create_permission(role_editor, 'reader')
expect(described_class.new(user_editor, child_category).access_effective).to eq 'reader'
end
it 'editor with reader permission on KB returns reader' do
create_permission(role_editor, 'reader', permissionable: knowledge_base)
expect(described_class.new(user_editor, category).access_effective).to eq 'reader'
end
it 'editor with reader permission on parent category but editor permission on category returns editor' do
create_permission(role_editor, 'reader', permissionable: category)
create_permission(role_editor, 'editor', permissionable: child_category)
expect(described_class.new(user_editor, child_category).access_effective).to eq 'editor'
end
it 'editor with editor permission on parent category but reader permission on category returns reader' do
create_permission(role_editor, 'editor', permissionable: category)
create_permission(role_editor, 'reader', permissionable: child_category)
expect(described_class.new(user_editor, child_category).access_effective).to eq 'reader'
end
it 'reader with reader and non-effective permissions returns reader' do
create_permission(role_reader, 'reader')
create_permission(role_editor, 'editor')
expect(described_class.new(user_reader, category).access_effective).to eq 'reader'
end
it 'reader with no matching permissions returns reader' do
create_permission(role_editor, 'editor')
expect(described_class.new(user_reader, category).access_effective).to eq 'reader'
end
it 'retuns none when user not given' do
expect(described_class.new(nil, category).access_effective).to eq 'none'
end
end
def create_permission(role, access, permissionable: category)
create(:knowledge_base_permission, role: role, permissionable: permissionable, access: access)
end
end

View file

@ -0,0 +1,87 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe KnowledgeBase::InternalAssets do
include_context 'basic Knowledge Base' do
before do
draft_answer
internal_answer
published_answer
end
end
describe '#collect_assets' do
subject(:assets) { described_class.new(user).collect_assets }
context 'when for KB editor' do
let(:user) { create(:user, roles: Role.where(name: 'Admin')) }
it 'returns assets for all KB objects' do
expect(assets).to include_assets_of(knowledge_base, category, draft_answer, internal_answer, published_answer)
end
context 'when has editor permission' do
before do
KnowledgeBase::PermissionsUpdate.new(category).update! user.roles.first => 'editor'
end
it 'returns assets for all KB objects' do
expect(assets).to include_assets_of(knowledge_base, category, draft_answer, internal_answer, published_answer)
end
end
context 'when has reader permission' do
before do
KnowledgeBase::PermissionsUpdate.new(category).update! user.roles.first => 'reader'
end
it 'returns assets for internally visible KB objects' do
expect(assets)
.to include_assets_of(knowledge_base, category, internal_answer, published_answer)
.and not_include_assets_of(draft_answer)
end
end
context 'when has none permission' do
before do
KnowledgeBase::PermissionsUpdate.new(category).update! user.roles.first => 'none'
end
it 'does not return assets for internally visible KB objects' do
published_answer.destroy # make sure public item does not make category visible
expect(assets)
.to include_assets_of(knowledge_base)
.and not_include_assets_of(category, draft_answer, internal_answer, published_answer)
end
it 'returns assets for published answer and it\'s category' do
expect(assets)
.to include_assets_of(knowledge_base, category, published_answer)
.and not_include_assets_of(draft_answer, internal_answer)
end
end
end
context 'when for agent' do
let(:user) { create(:agent) }
it 'returns assets for all KB objects' do
expect(assets)
.to include_assets_of(knowledge_base, category, internal_answer, published_answer)
.and not_include_assets_of(draft_answer)
end
end
context 'when for customer' do
let(:user) { create(:customer) }
it 'returns assets for all KB objects' do
expect(assets)
.to include_assets_of(knowledge_base)
.and not_include_assets_of(category, draft_answer, internal_answer, published_answer)
end
end
end
end

View file

@ -0,0 +1,180 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe KnowledgeBase::PermissionsUpdate do
describe '#update!' do
include_context 'basic Knowledge Base'
let(:role_editor) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:role_another) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:child_category) { create(:knowledge_base_category, parent: category) }
describe 'updating itself' do
shared_examples 'updating itself' do |object_name:|
let(:object) { send(object_name) }
it 'adds role permission for self' do
described_class.new(object).update! role_editor => 'editor'
expect(object.permissions)
.to contain_exactly have_attributes(permissionable: object, role: role_editor, access: 'editor')
end
it 'adds additional role permission for self' do
described_class.new(object).update! role_editor => 'reader'
described_class.new(object).update! role_editor => 'reader', role_another => 'reader'
expect(object.permissions)
.to contain_exactly(have_attributes(role: role_editor), have_attributes(role: role_another))
end
it 'does not update when re-adding an existing permission' do
described_class.new(object).update! role_editor => 'reader'
travel 1.minute
expect { described_class.new(object).update! role_editor => 'reader' }
.not_to change(object, :updated_at)
end
end
context 'when saving role on KB itself' do
include_context 'updating itself', object_name: :knowledge_base
end
context 'when saving role on KB category' do
include_context 'updating itself', object_name: :category
end
end
describe 'updating descendants' do
context 'when saving role on KB itself' do
it 'adds effective permissions to descendant categories' do
described_class.new(knowledge_base).update! role_editor => 'reader'
expect(category.permissions_effective)
.to contain_exactly have_attributes(role: role_editor, access: 'reader', permissionable: knowledge_base)
end
it 'removing permission opens up access to descendants' do
described_class.new(knowledge_base).update! role_editor => 'editor'
described_class.new(knowledge_base).update!(**{})
expect(category.permissions_effective).to be_blank
end
context 'when category has editor role has editor role with editor permission' do
before do
described_class.new(category).update! role_editor => 'editor'
category.reload
travel 1.minute
end
it 'removes identical permissions on descendant roles' do
described_class.new(knowledge_base).update! role_editor => 'editor'
category.reload
expect(category.permissions_effective)
.to contain_exactly have_attributes(role: role_editor, access: 'editor', permissionable: knowledge_base)
end
end
end
context 'when saving role on KB category' do
it 'adds effective permissions to descendant roles' do
described_class.new(category).update! role_editor => 'reader'
expect(child_category.permissions_effective)
.to contain_exactly have_attributes(role: role_editor, access: 'reader', permissionable: category)
end
context 'when child category has editor role with editor permission' do
before do
described_class.new(child_category).update! role_editor => 'editor'
category.reload
child_category.reload
travel 1.minute
end
it 'removes conflicting permissions on descendant roles' do
described_class.new(category).update! role_editor => 'none'
category.reload
child_category.reload
expect(child_category.permissions_effective)
.to contain_exactly have_attributes(role: role_editor, access: 'none', permissionable: category)
end
it 'removes identical permissions on descendant roles' do
described_class.new(category).update! role_editor => 'editor'
category.reload
child_category.reload
expect(child_category.permissions_effective)
.to contain_exactly have_attributes(role: role_editor, access: 'editor', permissionable: category)
end
end
context 'when category has role editor with none permission' do
before do
described_class.new(category).update! role_editor => 'none'
category.reload
travel 1.minute
end
it 'removing permission opens up access to descendants' do
described_class.new(category).update!(**{})
category.reload
expect(child_category.permissions_effective).to be_blank
end
end
end
end
describe 'preventing user lockout' do
let(:user) { create(:admin) }
let(:role) { user.roles.first }
shared_examples 'preventing user lockout' do |object_name:|
let(:object) { send(object_name) }
it 'raises an error when saving a lockout change for a given user' do
expect { described_class.new(object, user).update! role => 'reader' }
.to raise_error(Exceptions::UnprocessableEntity)
end
it 'allows to save same change without a user' do
expect { described_class.new(object).update! role => 'reader' }.not_to raise_error
end
end
context 'when saving role on KB itself' do
include_context 'preventing user lockout', object_name: 'knowledge_base'
end
context 'when saving role on KB category' do
include_context 'preventing user lockout', object_name: 'category'
end
end
end
describe '#update_using_params!' do
subject(:updater) { described_class.new(category) }
let(:role) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:category) { create(:knowledge_base_category) }
it 'calls update! with given roles' do
updater.update_using_params!({ permissions: { role.id => 'editor' } })
expect(category.permissions.first).to have_attributes(role: role, access: 'editor', permissionable: category)
end
it 'raises an error when given a non existant role' do
expect { updater.update_using_params!({ permissions: { (role.id + 1) => 'editor' } }) }
.to raise_error(ActiveRecord::RecordNotFound)
end
end
end

View file

@ -16,6 +16,7 @@ RSpec.describe KnowledgeBase::Category, type: :model, current_user_id: 1 do
it { is_expected.to have_many(:answers) }
it { is_expected.to have_many(:children) }
it { is_expected.to have_many(:permissions) }
it { is_expected.to belong_to(:parent).optional }
it { is_expected.to belong_to(:knowledge_base) }

View file

@ -0,0 +1,50 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
require 'models/contexts/factory_context'
RSpec.describe KnowledgeBase::Permission, type: :model do
subject(:kb_category_permission) { create(:knowledge_base_permission) }
include_context 'basic Knowledge Base'
include_context 'factory'
describe '#permissionable' do
it { is_expected.to belong_to(:permissionable).touch(true) }
it 'allows multiple permissions for the same category' do
permission = build(:knowledge_base_permission, permissionable: kb_category_permission.permissionable)
permission.save
expect(permission).to be_persisted
end
it 'does not allow same role/permission conbination' do
permission = build(:knowledge_base_permission,
permissionable: kb_category_permission.permissionable,
role: kb_category_permission.role)
permission.save
expect(permission).not_to be_persisted
end
end
describe '#role' do
it { is_expected.to belong_to(:role) }
it 'allows multiple permissions for the same category' do
permission = build(:knowledge_base_permission, role: kb_category_permission.role)
permission.save
expect(permission).to be_persisted
end
end
describe '#access' do
it { is_expected.to validate_presence_of(:access).with_message(%r{}) }
it { is_expected.to allow_value('editor').for(:access) }
it { is_expected.to allow_value('reader').for(:access) }
it { is_expected.to allow_value('none').for(:access) }
it { is_expected.not_to allow_value('foobar').for(:access) }
end
end

View file

@ -0,0 +1,64 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
require 'policies/knowledge_base_policy_examples'
describe KnowledgeBase::AnswerPolicy do
subject(:policy) { described_class.new(user, record) }
let(:record) { create(:knowledge_base_answer) }
let(:user) { create(:user) }
shared_context 'with answer visibility' do |visible:, visible_internally:|
before do
allow(record).to receive(:visible?).and_return(visible)
allow(record).to receive(:visible_internally?).and_return(visible_internally)
end
end
describe '#show?' do
context 'when visible and visible internally' do
include_examples 'with answer visibility', visible: true, visible_internally: true
include_examples 'with KB policy check', editor: true, reader: true, none: true, method: :show?
end
context 'when visible internally only' do
include_examples 'with answer visibility', visible: false, visible_internally: true
include_examples 'with KB policy check', editor: true, reader: true, none: false, method: :show?
end
context 'when not visible' do
include_examples 'with answer visibility', visible: false, visible_internally: false
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :show?
end
end
describe '#show_public?' do
context 'when visible and visible internally' do
include_examples 'with answer visibility', visible: true, visible_internally: true
include_examples 'with KB policy check', editor: true, reader: true, none: true, method: :show_public?
end
context 'when visible internally only' do
include_examples 'with answer visibility', visible: false, visible_internally: true
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :show_public?
end
context 'when not visible' do
include_examples 'with answer visibility', visible: false, visible_internally: false
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :show_public?
end
end
describe '#update?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :update?
end
describe '#create?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :create?
end
describe '#destroy?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :destroy?
end
end

View file

@ -0,0 +1,45 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
require 'policies/knowledge_base_policy_examples'
describe KnowledgeBase::CategoryPolicy do
subject(:policy) { described_class.new(user, record) }
let(:record) { create(:knowledge_base_category) }
let(:user) { create(:user) }
describe '#show?' do
include_examples 'with KB policy check', editor: true, reader: true, none: false, method: :show?
end
describe '#show_public?' do
context 'when category has public content' do
before { allow(record).to receive(:public_content?).and_return(true) }
include_examples 'with KB policy check', editor: true, reader: true, none: true, method: :show_public?
end
context 'when category has no public content' do
before { allow(record).to receive(:public_content?).and_return(false) }
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :show_public?
end
end
describe '#permissions?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :permissions?
end
describe '#update?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :update?
end
describe '#create?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :create?, access_method: :parent_access
end
describe '#destroy?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :destroy?, access_method: :parent_access
end
end

View file

@ -0,0 +1,29 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
RSpec.shared_context 'with KB policy check' do |editor:, reader:, none:, method:, access_method: :access|
let(:access_method) { access_method }
it 'returns true if editor' do
mock_permission 'editor'
expect(policy.send(method)).to be editor
end
it 'returns true if reader' do
mock_permission 'reader'
expect(policy.send(method)).to be reader
end
it 'returns false if none' do
mock_permission 'none'
expect(policy.send(method)).to be none
end
def mock_permission(access)
allow(policy)
.to receive(access_method)
.and_return(access)
end
end

View file

@ -0,0 +1,19 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
require 'policies/knowledge_base_policy_examples'
describe KnowledgeBasePolicy do
subject(:policy) { described_class.new(user, record) }
let(:record) { create(:knowledge_base) }
let(:user) { create(:user) }
describe '#show?' do
include_examples 'with KB policy check', editor: true, reader: true, none: false, method: :show?
end
describe 'update?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :update?
end
end

View file

@ -54,7 +54,9 @@ RSpec::Matchers.define :include_assets_of do
#
# @return [Hash, nil]
def find_assets_of(object, actual)
actual.dig(object.class.name.gsub(%r{::}, ''), object.id.to_s)
actual
.deep_stringify_keys
.dig(object.class.name.gsub(%r{::}, ''), object.id.to_s)
end
end

View file

@ -96,18 +96,6 @@ RSpec.describe 'Knowledge Base Locale Answer Edit', type: :system do
let(:new_tag_name) { 'capybara_kb_tag' }
it 'adds a new tag' do
within :active_content do
click '.js-newTagLabel'
elem = find('.js-newTagInput')
elem.fill_in with: new_tag_name
elem.send_keys :return
expect(page).to have_css('a.js-tag', text: new_tag_name)
end
end
it 'saves new tag to the database' do
within :active_content do
click '.js-newTagLabel'
@ -116,6 +104,7 @@ RSpec.describe 'Knowledge Base Locale Answer Edit', type: :system do
elem.send_keys :return
wait.until_exists { published_answer_with_tag.reload.tag_list.include? new_tag_name }
expect(page).to have_css('a.js-tag', text: new_tag_name)
end
end
@ -127,22 +116,10 @@ RSpec.describe 'Knowledge Base Locale Answer Edit', type: :system do
it 'deletes a tag' do
within :active_content do
click '.js-newTagLabel'
find('.list-item', text: published_answer_tag_name)
.find('.js-delete').click
expect(page).to have_no_css('a.js-tag', text: published_answer_tag_name)
end
end
it 'deletes the tag from the database' do
within :active_content do
click '.js-newTagLabel'
find('.list-item', text: published_answer_tag_name)
.find('.js-delete').click
wait.until_exists { published_answer_with_tag.reload.tag_list.exclude? published_answer_tag_name }
end
end

View file

@ -0,0 +1,177 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Knowledge Base Locale Category Permissions', type: :system do
include_context 'basic Knowledge Base'
let(:role_editor) { Role.find_by name: 'Admin' }
let(:role_another_editor) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:role_reader) { Role.find_by name: 'Agent' }
let(:child_category) { create(:knowledge_base_category, parent: category) }
it 'shows roles with has KB permissions only' do
open_page category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_text(%r{Admin}i)
.and(have_text(%r{Agent}i))
.and(have_no_text(%r{Customer}i))
end
end
describe 'permissions shown' do
it 'shows existing permissions when category has no permissions' do
open_page category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_editor.id}'][value='editor'][checked]:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_editor.id}'][value='reader']:not([disabled])", visible: :all))
.and(have_css("input[name='#{role_editor.id}'][value='none']:not([disabled])", visible: :all))
end
end
it 'shows existing permissions when category has inerited permissions only' do
KnowledgeBase::PermissionsUpdate.new(category).update! role_reader => 'none'
open_page category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_reader.id}'][value='reader']:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_reader.id}'][value='none'][checked]:not([disabled])", visible: :all))
end
end
it 'shows existing permissions' do
KnowledgeBase::PermissionsUpdate.new(child_category).update! role_reader => 'none'
open_page child_category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_reader.id}'][value='reader']:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_reader.id}'][value='none'][checked]:not([disabled])", visible: :all))
end
end
it 'shows editor permission not limited by parent category being read only' do
KnowledgeBase::PermissionsUpdate.new(category).update! role_another_editor => 'reader'
open_page child_category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_another_editor.id}'][value='none']:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_another_editor.id}'][value='reader'][checked]:not([disabled])", visible: :all))
.and(have_css("input[name='#{role_another_editor.id}'][value='editor'][disabled]", visible: :all))
end
end
it 'shows editor permissions limited by parent category' do
KnowledgeBase::PermissionsUpdate.new(category).update! role_another_editor => 'none'
open_page child_category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_another_editor.id}'][value='none'][checked]:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_another_editor.id}'][value='reader'][disabled]", visible: :all))
.and(have_css("input[name='#{role_another_editor.id}'][value='editor'][disabled]", visible: :all))
end
end
it 'shows reader permissions limited by parent category' do
KnowledgeBase::PermissionsUpdate.new(category).update! role_reader => 'none'
open_page child_category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_reader.id}'][value='none'][checked]:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_reader.id}'][value='reader'][disabled]", visible: :all))
end
end
end
describe 'saving changes' do
it 'saves permissions' do
open_page category
find('[data-action=permissions]').click
in_modal do
find("input[name='#{role_reader.id}'][value='none']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
end
expect(category.reload.permissions)
.to contain_exactly(
have_attributes(role: role_reader, access: 'none', permissionable: category),
have_attributes(role: role_editor, access: 'editor', permissionable: category)
)
end
it 'allows to modify existing permissions' do
KnowledgeBase::PermissionsUpdate.new(category).update! role_reader => 'none'
open_page category
find('[data-action=permissions]').click
in_modal do
find("input[name='#{role_reader.id}'][value='reader']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
end
expect(category.reload.permissions)
.to contain_exactly(
have_attributes(role: role_reader, access: 'reader', permissionable: category),
have_attributes(role: role_editor, access: 'editor', permissionable: category)
)
end
it 'does not allow to lock user himself' do
open_page category
find('[data-action=permissions]').click
in_modal disappears: false do
find("input[name='#{role_editor.id}'][value='reader']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
expect(page).to have_css('.alert')
end
end
end
def open_page(category)
visit "knowledge_base/#{knowledge_base.id}/locale/#{Locale.first.locale}/category/#{category.id}/edit"
end
end

View file

@ -0,0 +1,118 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Knowledge Base Locale Knowledge Base Permissions', type: :system do
include_context 'basic Knowledge Base'
let(:role_editor) { Role.find_by name: 'Admin' }
let(:role_another_editor) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:role_reader) { Role.find_by name: 'Agent' }
it 'shows roles with has KB permissions only' do
open_page
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_text(%r{Admin}i)
.and(have_text(%r{Agent}i))
.and(have_no_text(%r{Customer}i))
end
end
describe 'permissions shown' do
it 'shows existing permissions when KB has no permissions' do
open_page
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_editor.id}'][value='editor'][checked]", visible: :all)
.and(have_css("input[name='#{role_editor.id}'][value='reader']", visible: :all))
end
end
it 'shows existing permissions' do
KnowledgeBase::PermissionsUpdate.new(knowledge_base).update! role_another_editor => 'reader'
open_page
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_another_editor.id}'][value='reader'][checked]", visible: :all)
.and(have_css("input[name='#{role_another_editor.id}'][value='editor']", visible: :all))
end
end
end
describe 'saving changes' do
it 'saves permissions' do
role_another_editor
open_page
find('[data-action=permissions]').click
in_modal do
find("input[name='#{role_another_editor.id}'][value='reader']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
end
expect(knowledge_base.reload.permissions)
.to contain_exactly(
have_attributes(role: role_reader, access: 'reader', permissionable: knowledge_base),
have_attributes(role: role_another_editor, access: 'reader', permissionable: knowledge_base),
have_attributes(role: role_editor, access: 'editor', permissionable: knowledge_base)
)
end
it 'allows to modify existing permissions' do
KnowledgeBase::PermissionsUpdate.new(knowledge_base).update! role_another_editor => 'reader'
open_page
find('[data-action=permissions]').click
in_modal do
find("input[name='#{role_another_editor.id}'][value='editor']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
end
expect(knowledge_base.reload.permissions)
.to contain_exactly(
have_attributes(role: role_reader, access: 'reader', permissionable: knowledge_base),
have_attributes(role: role_another_editor, access: 'editor', permissionable: knowledge_base),
have_attributes(role: role_editor, access: 'editor', permissionable: knowledge_base)
)
end
it 'does not allow to lock user himself' do
open_page
find('[data-action=permissions]').click
in_modal disappears: false do
find("input[name='#{role_editor.id}'][value='reader']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
expect(page).to have_css('.alert')
end
end
end
def open_page
visit "knowledge_base/#{knowledge_base.id}/locale/#{Locale.first.locale}/edit"
end
end

View file

@ -939,7 +939,6 @@ RSpec.describe 'Ticket zoom', type: :system do
def forward
within :active_content do
# binding.pry
wait.until_exists { find('.textBubble-content .richtext-content') }
click '.js-ArticleAction[data-type=emailForward]'
fill_in 'To', with: 'customer@example.com'