Merge branch 'develop' of git.znuny.com:zammad/zammad into develop
This commit is contained in:
commit
5548ef1df8
26 changed files with 617 additions and 113 deletions
6
Gemfile
6
Gemfile
|
@ -66,6 +66,9 @@ require 'yaml'
|
||||||
|
|
||||||
gem 'net-ldap'
|
gem 'net-ldap'
|
||||||
|
|
||||||
|
# password security
|
||||||
|
gem 'argon2'
|
||||||
|
|
||||||
gem 'writeexcel'
|
gem 'writeexcel'
|
||||||
gem 'icalendar'
|
gem 'icalendar'
|
||||||
gem 'browser'
|
gem 'browser'
|
||||||
|
@ -115,6 +118,9 @@ group :development, :test do
|
||||||
|
|
||||||
# Setting ENV for testing purposes
|
# Setting ENV for testing purposes
|
||||||
gem 'figaro'
|
gem 'figaro'
|
||||||
|
|
||||||
|
# Use Factory Girl for generating random test data
|
||||||
|
gem 'factory_girl_rails'
|
||||||
end
|
end
|
||||||
|
|
||||||
gem 'puma', group: :puma
|
gem 'puma', group: :puma
|
||||||
|
|
13
Gemfile.lock
13
Gemfile.lock
|
@ -46,6 +46,9 @@ GEM
|
||||||
tzinfo (~> 1.1)
|
tzinfo (~> 1.1)
|
||||||
addressable (2.4.0)
|
addressable (2.4.0)
|
||||||
arel (6.0.3)
|
arel (6.0.3)
|
||||||
|
argon2 (1.1.1)
|
||||||
|
ffi (~> 1.9)
|
||||||
|
ffi-compiler (~> 0.1)
|
||||||
ast (2.3.0)
|
ast (2.3.0)
|
||||||
autoprefixer-rails (6.4.1.1)
|
autoprefixer-rails (6.4.1.1)
|
||||||
execjs
|
execjs
|
||||||
|
@ -107,11 +110,19 @@ GEM
|
||||||
erubis (2.7.0)
|
erubis (2.7.0)
|
||||||
eventmachine (1.2.0.1)
|
eventmachine (1.2.0.1)
|
||||||
execjs (2.7.0)
|
execjs (2.7.0)
|
||||||
|
factory_girl (4.8.0)
|
||||||
|
activesupport (>= 3.0.0)
|
||||||
|
factory_girl_rails (4.8.0)
|
||||||
|
factory_girl (~> 4.8.0)
|
||||||
|
railties (>= 3.0.0)
|
||||||
faraday (0.9.2)
|
faraday (0.9.2)
|
||||||
multipart-post (>= 1.2, < 3)
|
multipart-post (>= 1.2, < 3)
|
||||||
faraday-http-cache (1.3.1)
|
faraday-http-cache (1.3.1)
|
||||||
faraday (~> 0.8)
|
faraday (~> 0.8)
|
||||||
ffi (1.9.14)
|
ffi (1.9.14)
|
||||||
|
ffi-compiler (0.1.3)
|
||||||
|
ffi (>= 1.0.0)
|
||||||
|
rake
|
||||||
figaro (1.1.1)
|
figaro (1.1.1)
|
||||||
thor (~> 0.14)
|
thor (~> 0.14)
|
||||||
formatador (0.2.5)
|
formatador (0.2.5)
|
||||||
|
@ -396,6 +407,7 @@ PLATFORMS
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
activerecord-nulldb-adapter
|
activerecord-nulldb-adapter
|
||||||
activerecord-session_store
|
activerecord-session_store
|
||||||
|
argon2
|
||||||
autoprefixer-rails
|
autoprefixer-rails
|
||||||
biz
|
biz
|
||||||
browser
|
browser
|
||||||
|
@ -413,6 +425,7 @@ DEPENDENCIES
|
||||||
email_verifier
|
email_verifier
|
||||||
eventmachine
|
eventmachine
|
||||||
execjs
|
execjs
|
||||||
|
factory_girl_rails
|
||||||
figaro
|
figaro
|
||||||
github_changelog_generator
|
github_changelog_generator
|
||||||
guard
|
guard
|
||||||
|
|
1
VERSION
Normal file
1
VERSION
Normal file
|
@ -0,0 +1 @@
|
||||||
|
1.3.x
|
|
@ -44,7 +44,6 @@ class App.TicketOverview extends App.Controller
|
||||||
# TODO: fire @cancelDrag on ESC
|
# TODO: fire @cancelDrag on ESC
|
||||||
|
|
||||||
dragItem: (event) =>
|
dragItem: (event) =>
|
||||||
event.preventDefault()
|
|
||||||
pos = @batchDragger.data()
|
pos = @batchDragger.data()
|
||||||
threshold = 3
|
threshold = 3
|
||||||
x = event.pageX - pos.dx
|
x = event.pageX - pos.dx
|
||||||
|
@ -52,8 +51,12 @@ class App.TicketOverview extends App.Controller
|
||||||
dir = if event.pageY > pos.startY then 1 else -1
|
dir = if event.pageY > pos.startY then 1 else -1
|
||||||
|
|
||||||
if !pos.moved
|
if !pos.moved
|
||||||
if Math.abs(event.pageX - pos.startX) > threshold or Math.abs(event.pageY - pos.startY) > threshold
|
# trigger when moved a little up or down
|
||||||
|
# but don't trigge when moved left or right
|
||||||
|
# because the user might just want to select text
|
||||||
|
if Math.abs(event.pageY - pos.startY) - Math.abs(event.pageX - pos.startX) > threshold
|
||||||
@batchDragger.data 'moved', true
|
@batchDragger.data 'moved', true
|
||||||
|
@el.addClass('u-no-userselect')
|
||||||
# check grabbed items batch checkbox to make sure its checked
|
# check grabbed items batch checkbox to make sure its checked
|
||||||
# (could be grabbed without checking the checkbox it)
|
# (could be grabbed without checking the checkbox it)
|
||||||
@grabbedItemWasntChecked = !@grabbedItem.find('[name="bulk"]').prop('checked')
|
@grabbedItemWasntChecked = !@grabbedItem.find('[name="bulk"]').prop('checked')
|
||||||
|
@ -84,6 +87,8 @@ class App.TicketOverview extends App.Controller
|
||||||
else
|
else
|
||||||
return
|
return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
$.Velocity.hook @batchDragger, 'translateX', "#{x}px"
|
$.Velocity.hook @batchDragger, 'translateX', "#{x}px"
|
||||||
$.Velocity.hook @batchDragger, 'translateY', "#{y}px"
|
$.Velocity.hook @batchDragger, 'translateY', "#{y}px"
|
||||||
|
|
||||||
|
@ -103,15 +108,17 @@ class App.TicketOverview extends App.Controller
|
||||||
scale: 1.1
|
scale: 1.1
|
||||||
options:
|
options:
|
||||||
duration: 200
|
duration: 200
|
||||||
complete: ->
|
complete: =>
|
||||||
@hoveredBatchEntry.velocity 'reverse',
|
@hoveredBatchEntry.velocity 'reverse',
|
||||||
duration: 200
|
duration: 200
|
||||||
complete: =>
|
complete: =>
|
||||||
# clean scale
|
# clean scale
|
||||||
|
action = @hoveredBatchEntry.attr('data-action')
|
||||||
|
items = @el.find('[name="bulk"]:checked')
|
||||||
@hoveredBatchEntry.removeAttr('style')
|
@hoveredBatchEntry.removeAttr('style')
|
||||||
@cleanUpDrag(true)
|
@cleanUpDrag(true)
|
||||||
@performBatchAction @hoveredBatchEntry.attr('data-action')
|
|
||||||
@hoveredBatchEntry = null
|
@performBatchAction items, action
|
||||||
@batchDragger.velocity
|
@batchDragger.velocity
|
||||||
properties:
|
properties:
|
||||||
scale: 0
|
scale: 0
|
||||||
|
@ -125,7 +132,9 @@ class App.TicketOverview extends App.Controller
|
||||||
|
|
||||||
cleanUpDrag: (success) ->
|
cleanUpDrag: (success) ->
|
||||||
@hideBatchOverlay()
|
@hideBatchOverlay()
|
||||||
|
@el.removeClass('u-no-userselect')
|
||||||
$('.batch-dragger').remove()
|
$('.batch-dragger').remove()
|
||||||
|
@hoveredBatchEntry = null
|
||||||
|
|
||||||
if @grabbedItemWasntChecked
|
if @grabbedItemWasntChecked
|
||||||
@grabbedItem.find('[name="bulk"]').prop('checked', false)
|
@grabbedItem.find('[name="bulk"]').prop('checked', false)
|
||||||
|
@ -151,8 +160,8 @@ class App.TicketOverview extends App.Controller
|
||||||
easing: 'ease-in-out'
|
easing: 'ease-in-out'
|
||||||
duration: 300
|
duration: 300
|
||||||
|
|
||||||
performBatchAction: (action) ->
|
performBatchAction: (items, action) ->
|
||||||
console.log "perform action #{action} on checked items"
|
console.log "perform action #{action} on #{items.length} checked items"
|
||||||
|
|
||||||
showBatchOverlay: ->
|
showBatchOverlay: ->
|
||||||
@batchOverlay.show()
|
@batchOverlay.show()
|
||||||
|
@ -755,7 +764,7 @@ class Table extends App.Controller
|
||||||
checkbox: checkbox
|
checkbox: checkbox
|
||||||
)
|
)
|
||||||
table = $(table)
|
table = $(table)
|
||||||
table.delegate('[name="bulk_all"]', 'click', (e) ->
|
table.delegate('[name="bulk_all"]', 'change', (e) ->
|
||||||
if $(e.currentTarget).prop('checked')
|
if $(e.currentTarget).prop('checked')
|
||||||
$(e.currentTarget).closest('table').find('[name="bulk"]').prop('checked', true)
|
$(e.currentTarget).closest('table').find('[name="bulk"]').prop('checked', true)
|
||||||
else
|
else
|
||||||
|
@ -891,7 +900,7 @@ class Table extends App.Controller
|
||||||
@bulkForm.show()
|
@bulkForm.show()
|
||||||
|
|
||||||
# show/hide bulk action
|
# show/hide bulk action
|
||||||
@$('.table-overview').delegate('input[name="bulk"], input[name="bulk_all"]', 'click', (e) =>
|
@$('.table-overview').delegate('input[name="bulk"], input[name="bulk_all"]', 'change', (e) =>
|
||||||
if @$('.table-overview').find('input[name="bulk"]:checked').length == 0
|
if @$('.table-overview').find('input[name="bulk"]:checked').length == 0
|
||||||
@bulkForm.hide()
|
@bulkForm.hide()
|
||||||
@bulkForm.reset()
|
@bulkForm.reset()
|
||||||
|
@ -900,9 +909,21 @@ class Table extends App.Controller
|
||||||
)
|
)
|
||||||
|
|
||||||
# deselect bulk_all if one item is uncheck observ
|
# deselect bulk_all if one item is uncheck observ
|
||||||
@$('.table-overview').delegate('[name="bulk"]', 'click', (e) ->
|
@$('.table-overview').delegate('[name="bulk"]', 'change', (e) =>
|
||||||
if !$(e.target).prop('checked')
|
bulkAll = @$('.table-overview').find('[name="bulk_all"]')
|
||||||
$(e.target).parents().find('[name="bulk_all"]').prop('checked', false)
|
checkedCount = @$('.table-overview').find('input[name="bulk"]:checked').length
|
||||||
|
checkboxCount = @$('.table-overview').find('input[name="bulk"]').length
|
||||||
|
|
||||||
|
if checkedCount is 0
|
||||||
|
bulkAll.prop('indeterminate', false)
|
||||||
|
bulkAll.prop('checked', false)
|
||||||
|
else
|
||||||
|
if checkedCount is checkboxCount
|
||||||
|
bulkAll.prop('indeterminate', false)
|
||||||
|
bulkAll.prop('checked', true)
|
||||||
|
else
|
||||||
|
bulkAll.prop('checked', false)
|
||||||
|
bulkAll.prop('indeterminate', true)
|
||||||
)
|
)
|
||||||
|
|
||||||
getSelected: ->
|
getSelected: ->
|
||||||
|
|
28
app/assets/javascripts/app/controllers/version.coffee
Normal file
28
app/assets/javascripts/app/controllers/version.coffee
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
class Index extends App.ControllerSubContent
|
||||||
|
requiredPermission: 'admin.version'
|
||||||
|
header: 'Version'
|
||||||
|
|
||||||
|
constructor: ->
|
||||||
|
super
|
||||||
|
@load()
|
||||||
|
|
||||||
|
# fetch data, render view
|
||||||
|
load: ->
|
||||||
|
@startLoading()
|
||||||
|
@ajax(
|
||||||
|
id: 'version'
|
||||||
|
type: 'GET'
|
||||||
|
url: "#{@apiPath}/version"
|
||||||
|
success: (data) =>
|
||||||
|
@stopLoading()
|
||||||
|
@version = data.version
|
||||||
|
@render()
|
||||||
|
)
|
||||||
|
|
||||||
|
render: ->
|
||||||
|
|
||||||
|
@html App.view('version')(
|
||||||
|
version: @version
|
||||||
|
)
|
||||||
|
|
||||||
|
App.Config.set('Version', { prio: 3800, name: 'Version', parent: '#system', target: '#system/version', controller: Index, permission: ['admin.version'] }, 'NavBarAdmin' )
|
|
@ -10,6 +10,7 @@
|
||||||
<input type="checkbox" value="" name="bulk_all">
|
<input type="checkbox" value="" name="bulk_all">
|
||||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||||
|
<%- @Icon('checkbox-indeterminate', 'icon-indeterminate') %>
|
||||||
</label>
|
</label>
|
||||||
</th>
|
</th>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
8
app/assets/javascripts/app/views/version.jst.eco
Normal file
8
app/assets/javascripts/app/views/version.jst.eco
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<div class="page-header-title">
|
||||||
|
<h1><%- @T('Version') %><small></small></h1>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<p>
|
||||||
|
<%- @T('This is Zammad version %s', @version) %>
|
||||||
|
</p>
|
||||||
|
</div>
|
|
@ -4,6 +4,7 @@
|
||||||
.icon-arrow-up { width: 13px; height: 7px; }
|
.icon-arrow-up { width: 13px; height: 7px; }
|
||||||
.icon-chat { width: 24px; height: 24px; }
|
.icon-chat { width: 24px; height: 24px; }
|
||||||
.icon-checkbox-checked { width: 11px; height: 11px; }
|
.icon-checkbox-checked { width: 11px; height: 11px; }
|
||||||
|
.icon-checkbox-indeterminate { width: 11px; height: 11px; }
|
||||||
.icon-checkbox { width: 11px; height: 11px; }
|
.icon-checkbox { width: 11px; height: 11px; }
|
||||||
.icon-checkmark { width: 16px; height: 14px; }
|
.icon-checkmark { width: 16px; height: 14px; }
|
||||||
.icon-clipboard { width: 16px; height: 16px; }
|
.icon-clipboard { width: 16px; height: 16px; }
|
||||||
|
|
|
@ -132,6 +132,10 @@ ul {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.u-no-userselect {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.u-textTruncate {
|
.u-textTruncate {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -1018,76 +1022,82 @@ th.align-right {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
.icon-checked {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-unchecked {
|
||||||
|
color: hsl(60,1%,61%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-indeterminate {
|
||||||
|
display: none;
|
||||||
|
color: hsl(60,1%,61%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.checkbox-replacement--fullscreen,
|
||||||
|
&.radio-replacement--fullscreen {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.checkbox-replacement--inline,
|
||||||
|
&.radio-replacement--inline {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
&:disabled ~ .icon {
|
||||||
|
opacity: 0.33;
|
||||||
|
fill: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:checked) ~ .icon-checked,
|
||||||
|
&:checked ~ .icon-unchecked {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus:not(.is-active) ~ .icon-checked,
|
||||||
|
&:focus:not(.is-active) ~ .icon-unchecked {
|
||||||
|
box-shadow: 0 0 0 2px hsl(201,62%,90%);
|
||||||
|
color: hsl(200,71%,59%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:indeterminate {
|
||||||
|
~ .icon-checked,
|
||||||
|
~ .icon-unchecked {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
~ .icon-indeterminate {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+ .label-text {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-replacement.checkbox-replacement--fullscreen,
|
.radio-replacement {
|
||||||
.radio-replacement.radio-replacement--fullscreen {
|
input:focus ~ .icon-checked,
|
||||||
position: absolute;
|
input:focus ~ .icon-unchecked {
|
||||||
left: 0;
|
border-radius: 100%;
|
||||||
top: 0;
|
}
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.checkbox-replacement.checkbox-replacement--inline,
|
|
||||||
.radio-replacement.radio-replacement--inline {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-right: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-replacement input,
|
|
||||||
.radio-replacement input {
|
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-replacement .icon-checked,
|
|
||||||
.radio-replacement .icon-checked {
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-replacement .icon-unchecked,
|
|
||||||
.radio-replacement .icon-unchecked {
|
|
||||||
color: hsl(60,1%,61%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-replacement.is-disabled,
|
|
||||||
.radio-replacement.is-disabled {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-replacement input:disabled ~.icon,
|
|
||||||
.radio-replacement input:disabled ~.icon {
|
|
||||||
opacity: 0.33;
|
|
||||||
fill: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-replacement input:not(:checked) ~ .icon-checked,
|
|
||||||
.checkbox-replacement input:checked ~ .icon-unchecked,
|
|
||||||
.radio-replacement input:not(:checked) ~ .icon-checked,
|
|
||||||
.radio-replacement input:checked ~ .icon-unchecked {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-replacement input:focus:not(.is-active) ~ .icon-checked,
|
|
||||||
.checkbox-replacement input:focus:not(.is-active) ~ .icon-unchecked,
|
|
||||||
.radio-replacement input:focus:not(.is-active) ~ .icon-checked,
|
|
||||||
.radio-replacement input:focus:not(.is-active) ~ .icon-unchecked, {
|
|
||||||
box-shadow: 0 0 0 2px hsl(201,62%,90%);
|
|
||||||
color: hsl(200,71%,59%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-replacement input:focus ~ .icon-checked,
|
|
||||||
.radio-replacement input:focus ~ .icon-unchecked {
|
|
||||||
border-radius: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-replacement + .label-text,
|
|
||||||
.radio-replacement + .label-text {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table .checkbox-replacement,
|
.table .checkbox-replacement,
|
||||||
|
@ -2639,7 +2649,8 @@ ol.tabs li {
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-checkbox,
|
.icon-checkbox,
|
||||||
.icon-checkbox-checked {
|
.icon-checkbox-checked,
|
||||||
|
.icon-checkbox-indeterminate {
|
||||||
fill: white;
|
fill: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3836,7 +3847,7 @@ footer {
|
||||||
|
|
||||||
.popover .two-columns,
|
.popover .two-columns,
|
||||||
.popover .three-columns {
|
.popover .three-columns {
|
||||||
margin-bottom: -8px;
|
margin-top: -8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover .column label {
|
.popover .column label {
|
||||||
|
@ -3844,7 +3855,7 @@ footer {
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover .column {
|
.popover .column {
|
||||||
margin-bottom: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover--notifications {
|
.popover--notifications {
|
||||||
|
@ -8360,6 +8371,7 @@ output {
|
||||||
will-change: display;
|
will-change: display;
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&-backdrop {
|
&-backdrop {
|
||||||
@extend .fit;
|
@extend .fit;
|
||||||
|
|
13
app/controllers/version_controller.rb
Normal file
13
app/controllers/version_controller.rb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# Copyright (C) 2012-2017 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
class VersionController < ApplicationController
|
||||||
|
before_action { authentication_check(permission: 'admin.version') }
|
||||||
|
|
||||||
|
# GET /api/v1/version
|
||||||
|
def index
|
||||||
|
render json: {
|
||||||
|
version: Version.get
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -32,7 +32,7 @@ class User < ApplicationModel
|
||||||
load 'user/search_index.rb'
|
load 'user/search_index.rb'
|
||||||
include User::SearchIndex
|
include User::SearchIndex
|
||||||
|
|
||||||
before_validation :check_name, :check_email, :check_login, :check_password
|
before_validation :check_name, :check_email, :check_login, :ensure_password
|
||||||
before_create :check_preferences_default, :validate_roles, :domain_based_assignment
|
before_create :check_preferences_default, :validate_roles, :domain_based_assignment
|
||||||
before_update :check_preferences_default, :validate_roles
|
before_update :check_preferences_default, :validate_roles
|
||||||
after_create :avatar_for_email_check
|
after_create :avatar_for_email_check
|
||||||
|
@ -906,26 +906,23 @@ returns
|
||||||
Avatar.remove('User', id)
|
Avatar.remove('User', id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_password
|
def ensure_password
|
||||||
|
return if password_empty?
|
||||||
# set old password again if not given
|
return if PasswordHash.crypted?(password)
|
||||||
if password.blank?
|
self.password = PasswordHash.crypt(password)
|
||||||
|
|
||||||
# get current record
|
|
||||||
if id
|
|
||||||
#current = User.find(self.id)
|
|
||||||
#self.password = current.password
|
|
||||||
self.password = password_was
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
# crypt password if not already crypted
|
|
||||||
return if !password
|
|
||||||
return if password =~ /^\{sha2\}/
|
|
||||||
|
|
||||||
crypted = Digest::SHA2.hexdigest(password)
|
|
||||||
self.password = "{sha2}#{crypted}"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def password_empty?
|
||||||
|
# set old password again if not given
|
||||||
|
return if password.present?
|
||||||
|
|
||||||
|
# skip if it's not desired to set a password (yet)
|
||||||
|
return true if !password
|
||||||
|
|
||||||
|
# get current record
|
||||||
|
return if !id
|
||||||
|
|
||||||
|
self.password = password_was
|
||||||
|
true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
6
config/routes/version.rb
Normal file
6
config/routes/version.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
Zammad::Application.routes.draw do
|
||||||
|
api_path = Rails.configuration.api_path
|
||||||
|
|
||||||
|
match api_path + '/version', to: 'version#index', via: :get
|
||||||
|
|
||||||
|
end
|
Binary file not shown.
143
contrib/nginx/zammad_ssl.conf
Normal file
143
contrib/nginx/zammad_ssl.conf
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
#
|
||||||
|
# this is an example nginx config for zammad with free letsencrypt.org ssl certificates
|
||||||
|
# replace all occurrences of example.com with your domain
|
||||||
|
# when creating letsencrypt certificates the first time comment out the https parts in the config or nginx will not start
|
||||||
|
# create letsencrypt certificate by: /usr/bin/letsencrypt certonly --rsa-key-size 4096 --duplicate --text --webroot-path /var/www/html/ --webroot -d example.com -d www.example.com
|
||||||
|
# create dhparam.pem by: openssl dhparam -out /etc/nginx/ssl/dhparam.pem 4096
|
||||||
|
# download x3 certificate by: wget -q https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem -P /etc/nginx/ssl
|
||||||
|
# you can test your ssl configuration @ https://www.ssllabs.com/ssltest/analyze.html
|
||||||
|
#
|
||||||
|
|
||||||
|
upstream zammad {
|
||||||
|
server localhost:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream zammad-websocket {
|
||||||
|
server localhost:6042;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
|
||||||
|
server_name example.com www.example.com;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/example.com.access.log;
|
||||||
|
error_log /var/log/nginx/example.com.error.log;
|
||||||
|
|
||||||
|
location /.well-known/ {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
rewrite ^/(.*)$ https://www.example.com/$1 permanent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
|
||||||
|
server_name example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/example.com-fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/example.com-privkey.pem;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||||
|
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
|
||||||
|
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 180m;
|
||||||
|
|
||||||
|
ssl_stapling on;
|
||||||
|
ssl_stapling_verify on;
|
||||||
|
|
||||||
|
ssl_trusted_certificate /etc/nginx/ssl/lets-encrypt-x3-cross-signed.pem;
|
||||||
|
|
||||||
|
resolver 8.8.8.8 8.8.4.4;
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/example.com.access.log;
|
||||||
|
error_log /var/log/nginx/example.com.error.log;
|
||||||
|
|
||||||
|
rewrite ^/(.*)$ https://www.example.com/$1 permanent;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
|
||||||
|
server_name www.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/example.com-fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/example.com-privkey.pem;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||||
|
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
|
||||||
|
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 180m;
|
||||||
|
|
||||||
|
ssl_stapling on;
|
||||||
|
ssl_stapling_verify on;
|
||||||
|
|
||||||
|
ssl_trusted_certificate /etc/nginx/ssl/lets-encrypt-x3-cross-signed.pem;
|
||||||
|
|
||||||
|
resolver 8.8.8.8 8.8.4.4;
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
|
|
||||||
|
location = /robots.txt {
|
||||||
|
access_log off; log_not_found off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /favicon.ico {
|
||||||
|
access_log off; log_not_found off;
|
||||||
|
}
|
||||||
|
|
||||||
|
root /opt/zammad/public;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/example.com.access.log;
|
||||||
|
error_log /var/log/nginx/example.com.error.log;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
location ~ ^/(assets/|robots.txt|humans.txt|favicon.ico) {
|
||||||
|
expires max;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header CLIENT_IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_pass http://zammad-websocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header CLIENT_IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 180;
|
||||||
|
proxy_pass http://zammad;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/xml text/css image/svg+xml application/javascript application/x-javascript application/json application/xml;
|
||||||
|
gzip_proxied any;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
20
db/migrate/20170126091128_application_secret_setting.rb
Normal file
20
db/migrate/20170126091128_application_secret_setting.rb
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
class ApplicationSecretSetting < ActiveRecord::Migration
|
||||||
|
def up
|
||||||
|
|
||||||
|
# return if it's a new setup
|
||||||
|
return if !Setting.find_by(name: 'system_init_done')
|
||||||
|
|
||||||
|
Setting.create_if_not_exists(
|
||||||
|
title: 'Application secret',
|
||||||
|
name: 'application_secret',
|
||||||
|
area: 'Core',
|
||||||
|
description: 'Defines the random application secret.',
|
||||||
|
options: {},
|
||||||
|
state: SecureRandom.hex(128),
|
||||||
|
preferences: {
|
||||||
|
permission: ['admin'],
|
||||||
|
},
|
||||||
|
frontend: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
12
db/seeds.rb
12
db/seeds.rb
|
@ -10,6 +10,18 @@
|
||||||
# clear old caches to start from scratch
|
# clear old caches to start from scratch
|
||||||
Cache.clear
|
Cache.clear
|
||||||
|
|
||||||
|
Setting.create_if_not_exists(
|
||||||
|
title: 'Application secret',
|
||||||
|
name: 'application_secret',
|
||||||
|
area: 'Core',
|
||||||
|
description: 'Defines the random application secret.',
|
||||||
|
options: {},
|
||||||
|
state: SecureRandom.hex(128),
|
||||||
|
preferences: {
|
||||||
|
permission: ['admin'],
|
||||||
|
},
|
||||||
|
frontend: false
|
||||||
|
)
|
||||||
Setting.create_if_not_exists(
|
Setting.create_if_not_exists(
|
||||||
title: 'System Init Done',
|
title: 'System Init Done',
|
||||||
name: 'system_init_done',
|
name: 'system_init_done',
|
||||||
|
|
|
@ -1,21 +1,30 @@
|
||||||
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
module Auth::Internal
|
module Auth::Internal
|
||||||
def self.check(username, password, _config, user)
|
|
||||||
|
# rubocop:disable Style/ModuleFunction
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def check(username, password, _config, user)
|
||||||
|
|
||||||
# return if no user exists
|
# return if no user exists
|
||||||
return false if !username
|
return false if !username
|
||||||
return false if !user
|
return false if !user
|
||||||
|
|
||||||
# sha auth check
|
if PasswordHash.legacy?(user.password, password)
|
||||||
if user.password =~ /^\{sha2\}/
|
update_password(user, password)
|
||||||
crypted = Digest::SHA2.hexdigest(password)
|
return user
|
||||||
return user if user.password == "{sha2}#{crypted}"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# plain auth check
|
return false if !PasswordHash.verified?(user.password, password)
|
||||||
return user if user.password == password
|
|
||||||
|
|
||||||
false
|
user
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_password(user, password)
|
||||||
|
user.password = PasswordHash.crypt(password)
|
||||||
|
user.save
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
48
lib/password_hash.rb
Normal file
48
lib/password_hash.rb
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
module PasswordHash
|
||||||
|
include ApplicationLib
|
||||||
|
|
||||||
|
# rubocop:disable Style/ModuleFunction
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def crypt(password)
|
||||||
|
argon2.create(password)
|
||||||
|
end
|
||||||
|
|
||||||
|
def verified?(pw_hash, password)
|
||||||
|
Argon2::Password.verify_password(password, pw_hash, secret)
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def crypted?(pw_hash)
|
||||||
|
return if !pw_hash
|
||||||
|
# taken from: https://github.com/technion/ruby-argon2/blob/7e1f4a2634316e370ab84150e4f5fd91d9263713/lib/argon2.rb#L33
|
||||||
|
return if pw_hash !~ /^\$argon2i\$.{,112}/
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def legacy?(pw_hash, password)
|
||||||
|
return if pw_hash.blank?
|
||||||
|
return if !password
|
||||||
|
legacy_sha2?(pw_hash, password)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def legacy_sha2?(pw_hash, password)
|
||||||
|
return if !pw_hash.start_with?('{sha2}')
|
||||||
|
crypted = Digest::SHA2.hexdigest(password)
|
||||||
|
pw_hash == "{sha2}#{crypted}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def argon2
|
||||||
|
return @argon2 if @argon2
|
||||||
|
@argon2 = Argon2::Password.new(secret: secret)
|
||||||
|
end
|
||||||
|
|
||||||
|
def secret
|
||||||
|
Setting.get('application_secret')
|
||||||
|
end
|
||||||
|
end
|
32
lib/version.rb
Normal file
32
lib/version.rb
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# Copyright (C) 2012-2017 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
class Version
|
||||||
|
|
||||||
|
=begin
|
||||||
|
|
||||||
|
Returns version number of application
|
||||||
|
|
||||||
|
version = Version.get
|
||||||
|
|
||||||
|
returns
|
||||||
|
|
||||||
|
'1.3.0' # example
|
||||||
|
|
||||||
|
=end
|
||||||
|
|
||||||
|
def self.get
|
||||||
|
|
||||||
|
begin
|
||||||
|
version = File.read("#{Rails.root}/VERSION")
|
||||||
|
version.strip!
|
||||||
|
rescue => e
|
||||||
|
message = e.to_s
|
||||||
|
Rails.logger.error "VERSION file could not be read: #{message}"
|
||||||
|
|
||||||
|
version = ''
|
||||||
|
end
|
||||||
|
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
14
public/assets/images/icons/checkbox-indeterminate.svg
Normal file
14
public/assets/images/icons/checkbox-indeterminate.svg
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="11px" height="11px" viewBox="0 0 11 11" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 42 (36781) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title>checkbox-indeterminate</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<defs></defs>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="checkbox-indeterminate">
|
||||||
|
<polygon id="Path-Copy" fill="#50E3C2" points="11 0 0 0 0 11 11 11"></polygon>
|
||||||
|
<path d="M0,0 L0.55,0 L10.45,0 L11,0 L11,0.55 L11,10.45 L11,11 L10.45,11 L0.55,11 L0,11 L0,10.45 L0,0.55 L0,0 Z M10,1 L1,1 L1,10 L10,10 L10,1 Z" id="Path" fill="#BD0FE1"></path>
|
||||||
|
<rect id="Rectangle" fill="#000000" x="3" y="5" width="5" height="1"></rect>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 884 B |
31
spec/auth/internal_spec.rb
Normal file
31
spec/auth/internal_spec.rb
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Auth::Internal do
|
||||||
|
|
||||||
|
it 'authenticates via password' do
|
||||||
|
user = create(:user)
|
||||||
|
password = 'zammad'
|
||||||
|
result = described_class.check(user.login, password, {}, user)
|
||||||
|
|
||||||
|
expect(result).to be_an_instance_of(User)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't authenticate via plain password" do
|
||||||
|
user = create(:user)
|
||||||
|
result = described_class.check(user.login, user.password, {}, user)
|
||||||
|
|
||||||
|
expect(result).to be_falsy
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts legacy sha2 passwords' do
|
||||||
|
user = create(:user_legacy_password_sha2)
|
||||||
|
password = 'zammad'
|
||||||
|
|
||||||
|
expect(PasswordHash.crypted?(user.password)).to be_falsy
|
||||||
|
|
||||||
|
result = described_class.check(user.login, password, {}, user)
|
||||||
|
|
||||||
|
expect(result).to be_an_instance_of(User)
|
||||||
|
expect(PasswordHash.crypted?(user.password)).to be true
|
||||||
|
end
|
||||||
|
end
|
24
spec/factories/user.rb
Normal file
24
spec/factories/user.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
FactoryGirl.define do
|
||||||
|
sequence :email do |n|
|
||||||
|
"nicole.braun#{n}@zammad.org"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
FactoryGirl.define do
|
||||||
|
|
||||||
|
factory :user do
|
||||||
|
login 'nicole.braun'
|
||||||
|
firstname 'Nicole'
|
||||||
|
lastname 'Braun'
|
||||||
|
email { generate(:email) }
|
||||||
|
password 'zammad'
|
||||||
|
active true
|
||||||
|
updated_by_id 1
|
||||||
|
created_by_id 1
|
||||||
|
end
|
||||||
|
|
||||||
|
factory :user_legacy_password_sha2, parent: :user do
|
||||||
|
after(:build) { |user| user.class.skip_callback(:validation, :before, :ensure_password) }
|
||||||
|
password '{sha2}dd9c764fa7ea18cd992c8600006d3dc3ac983d1ba22e9ba2d71f6207456be0ba' # zammad
|
||||||
|
end
|
||||||
|
end
|
60
spec/password_hash_spec.rb
Normal file
60
spec/password_hash_spec.rb
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PasswordHash do
|
||||||
|
|
||||||
|
let(:pw_plain) { 'zammad' }
|
||||||
|
|
||||||
|
context 'stable API' do
|
||||||
|
it 'responds to crypt' do
|
||||||
|
expect(described_class).to respond_to(:crypt)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'responds to verified?' do
|
||||||
|
expect(described_class).to respond_to(:verified?)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'responds to crypted?' do
|
||||||
|
expect(described_class).to respond_to(:crypted?)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'responds to legacy?' do
|
||||||
|
expect(described_class).to respond_to(:legacy?)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'encryption' do
|
||||||
|
|
||||||
|
it 'crypts passwords' do
|
||||||
|
pw_crypted = described_class.crypt(pw_plain)
|
||||||
|
expect(pw_crypted).not_to eq(pw_plain)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'verifies crypted passwords' do
|
||||||
|
pw_crypted = described_class.crypt(pw_plain)
|
||||||
|
expect(described_class.verified?(pw_crypted, pw_plain)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'detects crypted passwords' do
|
||||||
|
pw_crypted = described_class.crypt(pw_plain)
|
||||||
|
expect(described_class.crypted?(pw_crypted)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'legacy' do
|
||||||
|
|
||||||
|
let(:zammad_sha2) { '{sha2}dd9c764fa7ea18cd992c8600006d3dc3ac983d1ba22e9ba2d71f6207456be0ba' }
|
||||||
|
|
||||||
|
it 'requires hash to be not blank' do
|
||||||
|
expect(described_class.legacy?(nil, pw_plain)).to be_falsy
|
||||||
|
expect(described_class.legacy?('', pw_plain)).to be_falsy
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires password to be not nil' do
|
||||||
|
expect(described_class.legacy?(zammad_sha2, nil)).to be_falsy
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'detects sha2 hashes' do
|
||||||
|
expect(described_class.legacy?(zammad_sha2, pw_plain)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -6,6 +6,7 @@ require File.expand_path('../../config/environment', __FILE__)
|
||||||
abort('The Rails environment is running in production mode!') if Rails.env.production?
|
abort('The Rails environment is running in production mode!') if Rails.env.production?
|
||||||
require 'spec_helper'
|
require 'spec_helper'
|
||||||
require 'rspec/rails'
|
require 'rspec/rails'
|
||||||
|
require 'support/factory_girl'
|
||||||
# Add additional requires below this line. Rails is not loaded until this point!
|
# Add additional requires below this line. Rails is not loaded until this point!
|
||||||
|
|
||||||
# Requires supporting ruby files with custom matchers and macros, etc, in
|
# Requires supporting ruby files with custom matchers and macros, etc, in
|
||||||
|
|
3
spec/support/factory_girl.rb
Normal file
3
spec/support/factory_girl.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
RSpec.configure do |config|
|
||||||
|
config.include FactoryGirl::Syntax::Methods
|
||||||
|
end
|
Loading…
Reference in a new issue