Compare commits

...

86 Commits

Author SHA1 Message Date
Florian Liebe
568ebbedd1 Release Zammad 6.5.0. 🚀 2025-04-02 08:12:02 +02:00
Martin Edenhofer
c81b1eef4f Applyed workaround for #5541 - The use of X-On-Behalf-Of in a REST call leads to a 500 error message
(cherry picked from commit 777f463d7f)

777f463d Applyed workaround for #5541 - The use of X-On-Behalf-Of in a REST call leads...

Co-authored-by: Martin Edenhofer <me@edenhofer.de>
2025-04-02 04:53:55 +02:00
Weblate
1c09be9296 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Random <random_r@users.noreply.translations.zammad.org>
Co-authored-by: sbaecker <baecker@uni-koblenz.de>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/de/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/it/
Translation: Zammad/Zammad (pre-release version 6.5)
2025-04-01 05:41:06 +00:00
Weblate
a911ea6a28 Maintenance: Update translated files and catalog 2025-03-31 06:52:21 +01:00
Weblate
0f6ba1064a Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Decio E. Silva <decio@verinatec.com.br>
Co-authored-by: Dmytro <ramadan2003@ukr.net>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: chrand818 <can@telenabler.com>
Co-authored-by: sigurbjartur <github@sigurbjartur.com>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/is/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/pt_BR/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sr_Cyrl/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sv/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/uk/
Translation: Zammad/Zammad (pre-release version 6.5)
2025-03-31 05:47:22 +00:00
Martin Gruner
6831317507 Maintenance: Switch to Ruby 3.2.8 2025-03-27 14:39:18 +01:00
Weblate
b835c523a1 Maintenance: Update translated files and catalog 2025-03-27 12:23:37 +00:00
Weblate
9a22f3b275 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Nikita <nyakovlev@iko.tech>
Co-authored-by: Random <random_r@users.noreply.translations.zammad.org>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/it/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/ru/
Translation: Zammad/Zammad (pre-release version 6.5)
2025-03-27 12:19:19 +00:00
Mantas Masalskis
b1d014adb5 Maintenance: Improve Out-of-office test spanning DST switch 2025-03-25 14:45:06 +01:00
Rolf Schmidt
1381cc4e8b Maintenance: Ignore gitlab revert branches.
Co-authored-by: Martin Gruner <mg@zammad.com>


(cherry picked from commit 0659978995)

2212917f Maintenance: Ignore gitlab revert branches.

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-24 09:07:32 +01:00
Martin Gruner
7501080071 Maintenance: apply security update for nokogiri 2025-03-24 07:38:06 +01:00
Mantas Masalskis
14b274a351 Maintenance: Improve stability of capybara tests with browser time travels 2025-03-21 18:35:18 +01:00
Rolf Schmidt
4431b58ea3 Revert "Maintenance: Remove breaking changes for #5495, will move 6.6."
This reverts commit 939212703c
2025-03-21 14:41:27 +01:00
Martin Gruner
2fbe854ef9 Maintenance: stabilize packager.io tests 2025-03-21 10:27:56 +01:00
Rolf Schmidt
939212703c Maintenance: Remove breaking changes for #5495, will move 6.6. 2025-03-21 09:26:57 +01:00
Mantas Masalskis
697f3c5eb5 Fixes #5525 - Can't use Channel::Driver::Imap: #<ArgumentError: unknown... 2025-03-21 07:34:51 +01:00
Mantas Masalskis
62459de9fc Fixes #5523 - Email replies are still sent if channel is deactivated 2025-03-20 21:57:38 +01:00
Mantas Masalskis
4cf023fcb0 Maintenance: Improve pre-created email addresses and channels in test environment 2025-03-20 21:11:55 +01:00
Martin Gruner
0409a03cf3 Maintenance: Add caching for corepack in CI 2025-03-20 19:50:27 +01:00
Mantas Masalskis
4c1b04f844 Maintenance: Improve Knowledge base answers API 2025-03-20 18:44:32 +01:00
Dusan Vuckovic
eae5fbf42c Fixes #5524 - Payload gets lost when a pre-defined webhook is cloned. 2025-03-20 16:03:52 +01:00
Martin Gruner
8fb3e08692 Maintenance: Stablilize browser test jobs by disabling external services 2025-03-20 13:30:21 +01:00
Florian Liebe
87b40c59d9 Maintenance: Update Microsoft Teams webhook spec. 2025-03-20 11:47:39 +01:00
Rolf Schmidt
4e3fd8859b Maintenance: Add env for capybara host.
(cherry picked from commit 669df22c69)

669df22c Maintenance: Add env for capybara host.

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-20 11:04:43 +01:00
Dominik Klein
3157469414 Maintenance: Fixed typos in email driver files. 2025-03-19 21:48:56 +01:00
Mantas Masalskis
df7da4ac78 Fixes #5518 - Password reset mails not sent with active import_mode 2025-03-19 15:39:38 +02:00
Dusan Vuckovic
e377c56cee Fixes #5519 - Mobile View: Highlighting/border of activated profile menu is a square instead of a circle. 2025-03-18 14:00:19 +01:00
Rolf Schmidt
593469e050 Revert "Performance: Cache attribute records for object manager attributes handling."
(cherry picked from commit 9fa89b3d42)

1e015f75 Revert "Performance: Cache attribute records for object manager attributes handling."

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-18 11:16:10 +01:00
Martin Gruner
305ed40a50 Maintenance: Add breaking changes file from develop 2025-03-18 07:33:57 +01:00
Mantas Masalskis
b413157394 Fixes #5520 - Search pagination and sorting display is wrong when coming from another taskbar 2025-03-17 15:13:53 +01:00
Dusan Vuckovic
712dd8affb Fixes #5516 - Removing admin permission from a role makes Settings > System screen empty. 2025-03-17 13:17:21 +01:00
Martin Gruner
01ac049327 Follow-up fc01cb5ec9 - Maintenance: Optimize... 2025-03-17 11:08:50 +01:00
Weblate
d5fbb8469c Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Random <random_r@users.noreply.translations.zammad.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: chrand818 <can@telenabler.com>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/it/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sv/
Translation: Zammad/Zammad (pre-release version 6.5)
2025-03-16 22:00:05 +00:00
Martin Gruner
18d25cb535 Maintenance: Optimize Chrome settings for CI usage 2025-03-16 18:53:02 +01:00
Mantas Masalskis
85621b75ac Fixes #5515 - Ticket merge dialog shows broken pagination 2025-03-14 19:14:46 +01:00
Rolf Schmidt
e1c3f67dfd Fixes #5240 - Incorrect very old log message for recursive trigger not executed.
(cherry picked from commit 13bf68792b)

bfd90212 Fixes #5240 - Incorrect log message for recursive trigger not executed; no...
cdebc802 fix wrong return

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-14 13:55:14 +01:00
Rolf Schmidt
d28538d74a Performance: Improve rendering for relation tree selects with >500 objects. 2025-03-14 09:31:28 +01:00
Rolf Schmidt
2d015252eb Performance: Cache attribute records for object manager attributes handling. 2025-03-14 09:31:16 +01:00
Rolf Schmidt
278d847f73 Maintenance: Make search params for relation list more patchable.
(cherry picked from commit 66d3b3f76f)

0c89c277 Maintenance: Make search params for relation list more patchable.
df1b6642 fix

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-14 09:21:46 +01:00
Martin Gruner
03ed62e92e Maintenance: Apply security updates for json and ruby-saml gems 2025-03-14 06:53:54 +01:00
Dominik Klein
6f90af12b0 Fixes #5514 - Migration for updating SAML help text is failing in some situations. 2025-03-13 12:00:58 +01:00
Martin Gruner
4e774a2890 Maintenance: Apply security update for graphql gem 2025-03-13 06:55:34 +01:00
Martin Gruner
3d93dedb02 Fallback to /proc/meminfo when lshw is unavailable 2025-03-12 13:36:57 +01:00
Mantas Masalskis
5b3504e854 Maintenance: Improve Ticket Shared Draft policy 2025-03-12 13:12:13 +01:00
Martin Gruner
198abe9293 Drop unused FTP CI service container 2025-03-12 12:05:31 +01:00
Mantas Masalskis
81faa6f165 Maintenance: Add Rubocop cop to ensure time zones in RSpec are set correctly 2025-03-12 11:13:05 +01:00
Martin Gruner
347a03641f Maintenance: Add thread name information to make thread dumps more useful 2025-03-12 09:34:10 +01:00
Martin Gruner
f41c682452 Maintenance: apply security update for rack gem 2025-03-12 07:52:27 +01:00
Martin Gruner
7a2569d7cf Fixes #5513 - Scheduler session nodes dying. 2025-03-11 12:14:23 +01:00
Rolf Schmidt
6c734230d6 Performance: Speed up set_fixed_to calls for bigger result sets.
Co-authored-by: Dominik Klein <dk@zammad.com>


(cherry picked from commit 28d8ec0ce2)

7bf68e87 Performance: Speed up set_fixed_to calls for bigger result sets.

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-11 10:27:21 +01:00
Rolf Schmidt
7e3b6f678a Fixes #5511 - Missing execution of core workflows when multiple create screens are opened on refresh.
(cherry picked from commit 863b417934)

5612df69 Fixes #5511 - Missing execution of core workflows when multiple create screens...
eada693e try loop instead
68bff62a cleanup loop

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-07 16:55:49 +01:00
Martin Gruner
f59b0a0b32 Fixes #5509 - Web concurrency setting inconsistently used/reported 2025-03-07 12:55:30 +01:00
Martin Gruner
9ff96f55df Maintenance: Include new session jobs env var in system report 2025-03-07 11:44:37 +01:00
Weblate
a0c0e64113 Maintenance: Update translated files and catalog 2025-03-07 06:07:51 +00:00
Weblate
bf5ef50af2 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: JPB <JPB@users.noreply.translations.zammad.org>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/nl/
Translation: Zammad/Zammad (pre-release version 6.5)
2025-03-06 22:00:08 +00:00
Mantas Masalskis
d37fc4eed4 Fixes #5505 - Tickets are sorted wrong in search 2025-03-06 16:38:56 +01:00
Florian Liebe
e1fdf81145 Fixes #5171 - Zammad sets an invalid Content-Type header for files with spaces in the filename.
Co-authored-by: Tobias Schäfer <ts@zammad.com>
2025-03-06 15:35:48 +01:00
Rolf Schmidt
18f82aef55 Maintenance: Make decision about self notification more patchable.
Co-authored-by: Florian Liebe <fl@zammad.com>


(cherry picked from commit d0884f47c9)

dfe330e5 Maintenance: Make decision about self notification more patchable.
88b3f874 fix missing param

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-06 11:12:10 +01:00
Weblate
040d0b75a5 Maintenance: Update translated files and catalog 2025-03-06 07:34:09 +00:00
Weblate
8fdfddc6bf Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Leif-Jöran Olsson <info@friprogramvarusyndikatet.se>
Co-authored-by: Murat Duman <mduman@metu.edu.tr>
Co-authored-by: Random <random_r@users.noreply.translations.zammad.org>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/it/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sr_Cyrl/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sv/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/tr/
Translation: Zammad/Zammad (pre-release version 6.5)
2025-03-06 07:21:36 +00:00
Martin Gruner
448b7e5902 Maintenance: Adapt docker test invocation 2025-03-06 08:19:17 +01:00
Martin Gruner
e1c1430f4a Maintenance: Apply security fix for rack gem 2025-03-06 07:11:49 +01:00
Tobias Schäfer
7342b7877a Fixes #4858 - Calendar non functional with activated 2fa authentication
Co-authored-by: Florian Liebe <fl@zammad.com>
2025-03-04 12:52:31 +01:00
Martin Gruner
a1dab8a8bb Maintenance: Improve github docker CI action 2025-03-04 10:33:55 +01:00
Rolf Schmidt
bb84c49e68 Follow up be06f0c - Fixes #5495 - Add global assets for ticket create screen.
Co-authored-by: Florian Liebe <fl@zammad.com>



(cherry picked from commit 269f97760d)

ab691973 fix requests for template and drafts.
fee9cb77 undo copy paste bug

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-03 20:45:23 +01:00
Rolf Schmidt
fde0613fcd Follow up be06f0c3881b3bfb447d487f5425a5cb598aa675 - Fixes #5495 - Remove obsolete requests for checklist templates.
Co-authored-by: Florian Liebe <fl@zammad.com>


(cherry picked from commit ac4aeb04c9)

912d5f0b Follow up be06f0c3881b3bfb447d487f5425a5cb598aa675 - Fixes #5495 - Remove...
204bc830 add render

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-03 20:45:08 +01:00
Rolf Schmidt
cb777e7cb7 Maintenance: Update cgi gem to 0.4.2 and uri to 1.0.3.
(cherry picked from commit dfc57d4797)

556411c0 Maintenance: Update cgi to 0.4.2.
e8db4279 Update uri gem.

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2025-03-03 19:55:37 +01:00
Mantas Masalskis
1062a9d365 Fixes #5499 - Drag´n´Drop of one attachment in the KnowledgeBase articles results in multiple copies 2025-03-03 15:31:56 +01:00
Mantas Masalskis
cf6d8b72c5 Maintenance: Stabilise custom object date validator spec 2025-03-03 12:52:39 +01:00
Weblate
36e3accbda Maintenance: Update translated files and catalog 2025-03-03 05:56:38 +00:00
Weblate
5847453164 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Christian Gravgaard <cg@bizperformer.com>
Co-authored-by: Jeroen Baten <jbaten@i2rs.nl>
Co-authored-by: Mantas Masalskis <mantas@idev.lt>
Co-authored-by: Murat Duman <mduman@metu.edu.tr>
Co-authored-by: Nikita <nyakovlev@iko.tech>
Co-authored-by: Ralf Schmid <rsc@zammad.com>
Co-authored-by: Rok Poje <rok.poje@mintpos.si>
Co-authored-by: Solenne Lefevre <solenne.lefevre@data-bene.io>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fabiuxxlinuxx <fabiux@gmail.com>
Co-authored-by: sashkashurik <sashkashurik@gmail.com>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/da/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/de/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/fr/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/fr_CA/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/it/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/lt/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/nl/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/ru/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sl/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/tr/
Translation: Zammad/Zammad (pre-release version 6.5)
2025-03-03 05:53:26 +00:00
Mantas Masalskis
8d4b3694e5 Fixes #5246 - Group selection not scrollable in bulk action with lot of groups 2025-02-28 15:17:27 +01:00
Rolf Schmidt
527c0bbf6c Follow up be06f0c3881b3bfb447d487f5425a5cb598aa675 - Fixes #5495 - Adjust... 2025-02-28 12:09:03 +01:00
Weblate
fb300d117d Maintenance: Update translated files and catalog 2025-02-28 05:21:34 +00:00
Weblate
ce06e08ee6 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Mantas Masalskis <mantas@idev.lt>
Co-authored-by: Random <random_r@users.noreply.translations.zammad.org>
Co-authored-by: Rob <robertborge@outlook.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/it/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/lt/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/nb_NO/
Translation: Zammad/Zammad (pre-release version 6.5)
2025-02-28 05:19:01 +00:00
Dusan Vuckovic
bb16fd5293 Maintenance: Update pnpm to v10 2025-02-27 19:09:32 +01:00
Dusan Vuckovic
29b69d1ea5 Fixes #5482 - Email Archive mode: Warn admins if they choose cut-off-date in the future. 2025-02-27 16:49:01 +00:00
Dusan Vuckovic
eb1eaf3f0a Follow up 340d9c10 - Fixes #5470 - Unify destination group setting and include e-mail-address in channel setup. 2025-02-27 15:30:07 +00:00
Florian Liebe
c1979c5ab7 Maintenance: Remove link to community thread from the M365 IMAP channel. 2025-02-27 14:27:30 +01:00
Martin Gruner
67d0f843af Maintenance: Fix failing docker builds 2025-02-27 11:28:30 +01:00
Martin Gruner
c21ad1b1e2 Maintenance: Stabilize future/past validator test 2025-02-27 08:23:51 +01:00
Dusan Vuckovic
8902d10c31 Fixes #4355 - Certain values in product_name will cause issues with sending agent notifications. 2025-02-26 17:33:31 +01:00
Martin Gruner
0348f6dd6d Maintenance: Improve docker CI configuration 2025-02-26 07:22:28 +01:00
Martin Gruner
10430cd1ce Maintenance: Improve configuration of packager.io CI jobs 2025-02-26 07:07:16 +01:00
Martin Gruner
a777172334 Maintenance: Improve configuration of packager.io CI jobs 2025-02-26 07:03:55 +01:00
Martin Gruner
8e7fea9624 Maintenance: Improve handling of webhook redirects 2025-02-25 09:51:33 +01:00
229 changed files with 16110 additions and 14464 deletions

View File

@@ -1,6 +1,9 @@
{
"private": true,
"type": "module",
"pnpm": {
"neverBuiltDependencies": []
},
"devDependencies": {
"@frsource/cypress-plugin-visual-regression-diff": "^3.3.10",
"@testing-library/cypress": "^10.0.3",

View File

@@ -2,6 +2,9 @@
"private": true,
"version": "1.0.0",
"description": "DX packages",
"pnpm": {
"neverBuiltDependencies": []
},
"dependencies": {
"hygen": "^6.2.11"
}

View File

@@ -0,0 +1,63 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module RuboCop
module Cop
module Zammad
# This spec ensures that time zones in system specs are set at the top level only.
# The problem is if time zone changes in the middle of the spec, browser does not pick it up.
# This leads to confusing issues when whole file vs specific portion are executed.
#
# Non-system tests are ignored. Timezone in pure RSPec tests can be set at any moment.
#
# @example
# # bad
# RSpec.describe 'Test', type: :system do
# it 'is fine', time_zone: 'Europe/London' do
# example
# end
# end
#
# # good
# RSpec.describe 'Test', type: :system, time_zone: 'Europe/London' do
# it 'is fine' do
# example
# end
# end
class EnsuresTimeZoneAtSpecTopLevel < Base
def_node_matcher :is_rspec_block_with_time_zone?, <<-PATTERN
$(send _ {:describe :context :it :shared_examples} (_ ...) (hash <(pair (sym :time_zone) (str...))...>))
PATTERN
def_node_matcher :is_system_type?, <<-PATTERN
$(block (send (const _ :RSpec) :describe (_ ...) (hash <(pair (sym :type) (sym :system))...>)...)...)
PATTERN
def_node_matcher :is_rspec_top_level_block?, <<-PATTERN
$(send (const _ :RSpec) :describe...)
PATTERN
MSG = 'RSpec system tests (aka Capybara) should set custom time zones at top level only'.freeze
def on_send(node)
return if !is_rspec_block_with_time_zone?(node)
return if is_rspec_top_level_block?(node)
return if !in_system_spec?(node)
add_offense(node)
end
def in_system_spec?(node)
top_parent = node.parent
loop do
break if !top_parent
return true if is_system_type?(top_parent)
top_parent = top_parent.parent
end
false
end
end
end
end
end

View File

@@ -492,3 +492,8 @@ Zammad/ExistsDbStrategy:
- "spec/**/*.rb"
Exclude:
- "spec/support/capybara/*.rb"
Zammad/EnsuresTimeZoneAtSpecTopLevel:
Enabled: true
Include:
- 'spec/system/**/*.rb'

View File

@@ -21,3 +21,4 @@ require_relative 'cop/zammad/timezone_default'
require_relative 'cop/zammad/to_forbid_over_not_to_permit'
require_relative 'cop/zammad/trigger_from_commit_hooks'
require_relative 'cop/zammad/update_copyright'
require_relative 'cop/zammad/ensures_time_zone_at_spec_top_level'

View File

@@ -16,11 +16,15 @@
"dependencies": {
"requireindex": "^1.2.0"
},
"pnpm": {
"neverBuiltDependencies": []
},
"devDependencies": {
"eslint": "^8.57.1",
"eslint-plugin-eslint-plugin": "^5.5.1",
"eslint-plugin-n": "^14.0.0",
"mocha": "^11.1.0"
"mocha": "^11.1.0",
"vue-eslint-parser": "^9.4.3"
},
"engines": {
"node": "12.x || 14.x || >= 16"

View File

@@ -24,6 +24,9 @@ importers:
mocha:
specifier: ^11.1.0
version: 11.1.0
vue-eslint-parser:
specifier: ^9.4.3
version: 9.4.3(eslint@8.57.1)
packages:
@@ -460,6 +463,9 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
@@ -602,6 +608,11 @@ packages:
resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
hasBin: true
semver@7.7.1:
resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==}
engines: {node: '>=10'}
hasBin: true
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
@@ -667,6 +678,12 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: '>=6.0.0'
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -1146,6 +1163,8 @@ snapshots:
lodash.merge@4.6.2: {}
lodash@4.17.21: {}
log-symbols@4.1.0:
dependencies:
chalk: 4.1.2
@@ -1282,6 +1301,8 @@ snapshots:
semver@6.3.0: {}
semver@7.7.1: {}
serialize-javascript@6.0.2:
dependencies:
randombytes: 2.1.0
@@ -1342,6 +1363,19 @@ snapshots:
dependencies:
punycode: 2.1.1
vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies:
debug: 4.3.5(supports-color@8.1.1)
eslint: 8.57.1
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
espree: 9.6.1
esquery: 1.4.2
lodash: 4.17.21
semver: 7.7.1
transitivePeerDependencies:
- supports-color
which@2.0.2:
dependencies:
isexe: 2.0.0

View File

@@ -44,6 +44,15 @@ jobs:
run: |
cd zammad-docker-compose
docker load --input /tmp/zammad-local.tar
docker compose -f docker-compose.yml -f docker-compose.override-local.yml up --detach
docker compose cp .github/auto_wizard.json zammad-railsserver:/opt/zammad/tmp
.github/tests.sh
cat - <<ENV_FILE >> .env
IMAGE_REPO=zammad-local
VERSION=latest
ENV_FILE
.github/tests/setup/default.sh
.github/tests/default.sh
docker compose ps
# Ensure we are running with the correct image
docker compose ps | grep zammad-local:latest

View File

@@ -14,6 +14,7 @@
files:
- pnpm-lock.yaml
paths:
- tmp/cache/corepack
- tmp/cache/pnpm-store
- tmp/cache/cypress

View File

@@ -1,5 +1,5 @@
default:
image: $CI_REGISTRY/docker/zammad-ci:3.2.7
image: $CI_REGISTRY/docker/zammad-ci:3.2.8
tags:
- docker

View File

@@ -38,10 +38,6 @@
IDOIT_API_ENDPOINT: 'http://ci-service-idoit:80'
IDOIT_API_TOKEN: 'admin'
.variables_ftp:
variables:
FTP_URL: 'ftp://ci-service-ftp'
.variables_auth:
variables:
KEYCLOAK_BASE_URL: 'http://ci-service-auth:8080'
@@ -112,10 +108,6 @@
name: $CI_REGISTRY/docker/zammad-idoit:stable
alias: ci-service-idoit
ftp:
name: $CI_REGISTRY/docker/zammad-ftp:stable
alias: ci-service-ftp
ldap:
name: $CI_REGISTRY/docker/zammad-ldap:stable
alias: ci-service-ldap

View File

@@ -1,6 +1,8 @@
# Global variables added to the ENV of each job and service container.
variables:
FF_NETWORK_PER_BUILD: 1 # https://docs.gitlab.com/runner/configuration/feature-flags.html
# For corepack download caching.
COREPACK_HOME: ${CI_PROJECT_DIR}/tmp/cache/corepack
# for faster translation loading
Z_LOCALES: 'en-us:de-de'
RAILS_ENV: 'test'

View File

@@ -9,6 +9,6 @@ workflow:
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
when: never
# Don't create branch pipelines for pushes to private branches (as there will also be a MR for them).
- if: $CI_COMMIT_BRANCH =~ /^(private|cherry-pick-|renovate|dependabot)/ && $CI_PIPELINE_SOURCE == "push"
- if: $CI_COMMIT_BRANCH =~ /^(private|cherry-pick-|revert-|renovate|dependabot)/ && $CI_PIPELINE_SOURCE == "push"
when: never
- when: always
- when: always

View File

@@ -20,7 +20,7 @@
stage: pre
cache: []
rules:
- if: $CI_COMMIT_REF_NAME =~ /^(private|cherry-pick-|renovate|dependabot)/
- if: $CI_COMMIT_REF_NAME =~ /^(private|cherry-pick-|revert-|renovate|dependabot)/
when: never
- when: always
before_script:

View File

@@ -3,7 +3,7 @@ docker:
stage: test
interruptible: false
rules:
- if: $CI_COMMIT_REF_NAME =~ /^private/
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_COMMIT_REF_NAME =~ /^private/
when: manual
allow_failure: true
- when: on_success
@@ -33,10 +33,12 @@ docker:
ports: !reset []
COMPOSE_OVERRIDE_FILE
docker compose up --quiet-pull --detach
docker compose cp .github/auto_wizard.json zammad-railsserver:/opt/zammad/tmp
DISABLE_EXTERNAL_TESTS=1 .github/tests.sh
.github/tests/setup/default.sh
DISABLE_EXTERNAL_TESTS=1 .github/tests/default.sh
docker compose ps
# Ensure we are running with the correct image
docker compose ps | grep ${ZAMMAD_IMAGE}
after_script:
- cd zammad-docker-compose
- docker compose down --timeout 0 --volumes

View File

@@ -8,7 +8,7 @@
- !reference [.scripts, pnpm_install_eslint_plugin_zammad]
script:
# Only run one worker to avoid load peaks affecting other jobs.
- pnpm test -- --min-workers=1 --max-workers=1 --shard $VITEST_SHARD
- pnpm test --min-workers=1 --max-workers=1 --shard $VITEST_SHARD
# Stop here, except on the first shard.
- "[[ $VITEST_SHARD = 1/* ]] || exit 0"
- echo "Also test eslint-plugin-zammad…"

View File

@@ -15,7 +15,7 @@
- !reference [.scripts, zammad_db_init]
# Then, switch to the current commit, migrate to it and run a few selected tests.
- git checkout $CI_COMMIT_SHA
- rvm use 3.2.7
- rvm use 3.2.8
- !reference [.scripts, bundle_install]
# Force redis usage, even if it was disabled by the initial configure_environment script of stable.
- export REDIS_URL=redis://redis

View File

@@ -3,7 +3,8 @@
stage: test
interruptible: false
rules:
- if: $CI_PIPELINE_SOURCE == "schedule" || $CI_PIPELINE_SOURCE == "web"
# These jobs can only run for a) published paths and b) when triggered after the packager.io build is complete.
- if: ($CI_PIPELINE_SOURCE == "schedule" || $CI_PIPELINE_SOURCE == "web") && $CI_COMMIT_REF_NAME =~ /^(stable|develop)/
services: []
cache: []
before_script: []

View File

@@ -2,10 +2,12 @@ performance:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == "schedule" || $CI_PIPELINE_SOURCE == "web"
allow_failure: true
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- .gitlab/ci/test/performance.yml
- .gitlab/performance/**/*
allow_failure: true
# Make sure this gets executed exclusively on a runner.
tags: ['no_concurrency']
services:
@@ -31,7 +33,7 @@ performance:
- bundle exec rails r tmp/performance/run_tests.rb
# Then, switch to the current commit, migrate to it and run a few selected tests.
- git checkout $CI_COMMIT_SHA
- rvm use 3.2.7
- rvm use 3.2.8
- !reference [.scripts, bundle_install]
# Force redis usage, even if it was disabled by the initial configure_environment script of stable.
- export REDIS_URL=redis://redis

View File

@@ -21,7 +21,6 @@ rspec:integration:
- .job_rules_integration
- .variables_proxy
- .variables_mattermost
- .variables_ftp
- .variables_s3
allow_failure: true
services:
@@ -31,7 +30,6 @@ rspec:integration:
- !reference [.services, redis]
- !reference [.services, memcached]
- !reference [.services, proxy]
- !reference [.services, ftp]
- !reference [.services, mattermost]
- !reference [.services, ldap]
- !reference [.services, s3]

View File

@@ -4,7 +4,7 @@ set -eu
cd "$(dirname $0)"
docker compose down --timeout 0 --volumes
docker compose down --timeout 0 --volumes || true # Ignore "prune operation is already running" errors.
DELETE_IMAGES=$(docker image ls "zammad-packagerio-ci-${CI_JOB_ID}" -q)
if [ -n "$DELETE_IMAGES" ]

View File

@@ -21,6 +21,6 @@ do
docker compose down --timeout 0
docker compose up -d
docker compose exec zammad bash "/scenarios/${SCENARIO}/${DISTRIBUTION}.sh"
docker compose down --timeout 0
docker compose down --timeout 0 --volumes || true # Ignore "prune operation is already running" errors.
done
./cleanup.sh

1
.npmrc
View File

@@ -1 +1,2 @@
node-linker=${PNPM_NODE_LINKER:-isolated}
strict-dep-builds=true

View File

@@ -1 +1 @@
3.2.7
3.2.8

205
BREAKING_CHANGES.md Normal file
View File

@@ -0,0 +1,205 @@
# Breaking Changes
## 6.5
### Textarea object manager attribute values
When used as [template variables](https://admin-docs.zammad.org/en/latest/misc/variables.html), the `textarea` object manager attributes are now replaced with an HTML representation of their value. This is a consequence of a bugfix for [#5330](https://github.com/zammad/zammad/issues/5330), which expects newline characters are respected for these attributes in all contexts, including the rich text article body. The administrators are advised to check for usage of such variables in their objects (e.g. triggers, text modules, etc), by making sure the new value type will not break their existing workflows.
### Changes to search API endpoints
All search endpoints (`/:object/search`) have been revised, extended and unified. This will also result in breaking changes in the existing endpoints. Be prepared that the structure in the responses may change (have a look at the [API documentation](https://docs.zammad.org/en/latest/api/intro.html) where you can find updated examples).
The **standard search** (e.g. `/ticket/search`) returned a hash for some objects, such as the ticket. This will no longer be the case.
The structure for the **expanded search** (e.g. `/ticket/search?expand=true`) remains the same.
The structure of the **full search** (e.g. `/ticket/search?full=true`) remains the same, but is supplemented by a `total_count`, which counts all results.
Some objects used an object-related hash key, such as `ticket_ids`. This is now always `record_ids`.
The **count search** (e.g. `/ticket/search?only_total_count=true`) is a new feature.
### API performance optimization of asset return data
Most API endpoints have some parameter called full=true which returns the data with all related assets. [In order to improve the front end performance](https://github.com/zammad/zammad/issues/5495), we decided to reduce certain assets which are populated on the login. Normally this parameter is mainly used to show additional data to our legacy app. We have some static data which rarely changes and are currently added on every object like:
- Ticket Priority
- Ticket State
- Ticket Group
- Role
This information will not be added in the future any more when requesting a user or a ticket. If you have an external script which is for example requesting tickets with assets and using the group name or some role information out of the assets, you need to adjust your code to do either:
1. Do a separate request to the object specific endpoint e.g. `/api/v1/groups`
2. Get the global assets before requesting the ticket e.g. `/api/v1/signshow`
### Limit to one `merged` state
It will not be possible to have more than one state of the `merged` type. Additional states will be changed to the `closed` type automatically with that update. If you want to prevent that, make sure to only have one state of the type `merged` before you make an update to the **next release**.
### `APP_RESTART_CMD` and Zammad self-shutdown
Zammad will now cause a self-shutdown of all running processes after certain configuration changes by default. It is the responsibility of the controlling process manager (e.g. Docker, Kubernetes, systemd) to bring them up again by way of a proper restart policy. This is the default for vanilla Docker, Kubernetes or Package deployments of Zammad.
The previous environment variable `APP_RESTART_CMD` is not supported any more.
For systems where this behaviour is not wanted, it can be disabled by setting the Zammad Setting `auto_shutdown` to `false`.
### `ZAMMAD_SESSION_JOBS_CONCURRENT` was renamed to `ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS`
The background worker shutdown handling [was improved](https://github.com/zammad/zammad/commit/b5141f6670920bd960ab4269dc80b1d09d7c8eb6)
to be more graceful and allow processes to finish first.
Together with this change, the session jobs logic that computes the ticket overviews was moved to be
a first-class background service. Therefore the name of its configuration variable changed
from `ZAMMAD_SESSION_JOBS_CONCURRENT` to `ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS`. The old one will continue
to work for now, but emit a deprecation warning.
### nginx configuration update
Please update your nginx configuration file to insert the line `proxy_set_header Host $http_host;` to the `location /ws` and `location /cable` sections like in the example below:
```diff
@@ -38,10 +38,11 @@ server {
# legacy web socket server
location /ws {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
+ 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_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_pass http://zammad-websocket;
@@ -50,10 +51,11 @@ server {
# action cable
location /cable {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
+ 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_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_pass http://zammad-railsserver;
```
## 6.4
### JavaScript Package Manager Change
This version changes the JavaScript toolchains to be based on pnpm rather than yarn. This is only relevant for source installations, where you need to provide a recent version of pnpm in order to run the assets:precompile command.
### Changed CSV-format for user/organization import/export
We slightly changed the format of the CSV-files you use for importing/exporting users and organizations. If you somehow do that in an automated process via our REST-API, you will need to review that process before updating to Zammad 6.4. Check the details in our documentation.
## 6.3
### Knowledge base granular permissions setup changed
Zammad allows configuration of a granular permission structure for knowledge base access. Previous Zammad versions allowed to misconfigure this in cases of allowing agents editor access to only some, but not all categories: it was possible to grant editor access at a higher level in the category tree, and then restricting access to reader or none for sub-categories. This was not effective due to permission inheritance.
Such a misconfiguration is no longer possible in Zammad 6.3. Administrators with existing knowledge base granular permission structures should review their configuration to ensure that at top-level only reader access is granted, and editor access only for the relevant sub-categories.
### Permissions for Ticket State and Priority REST-API
With this release, we will introduce new permissions for the ticket state and priority management. It will no longer be possible to access the corresponding REST-API with "admin.object" permission. Existing roles and/or access tokens that are used for these specific REST-API endpoints need to be updated to include the new permissions ("admin.ticket_state" and "admin.ticket_priority").
## 6.2
### Default SSL Verification in UserAgent
The default SSL behaviour of the *UserAgent* class in Zammad was changed. Previously, it would not perform SSL verification unless explicitly requested. Now, it will perform SSL verification unless explicitly rejected.
This may cause issues on systems with custom addons using the *UserAgent* to access other systems via *https*, if these systems have self-signed certificates. In such cases, these certificates or the CA certificates used for their generation should be uploaded via the new *SSL Certificates* management screen of Zammad. Alternatively, custom code can be adjusted to pass *verify_ssl: false* to *UserAgent* calls to restore the old behaviour.
### Oversized Email Handling
The handling of emails larger than the size limit changed. Previously, Zammad would send a reply and save the emails locally in *var/spool/oversized_email* (if the setting *postmaster_send_reject_if_mail_too_large* is *true*). No ticket is created in this case.
The new behaviour is that Zammad sends the reply like before, but no longer creates files for these emails locally - they are discarded.
### Reserved Delimiter in Group Name
The double colon (`::`) is now a reserved delimiter in the group name, in order to facilitate nested structure for complex hierarchies. Previously, it was possible to freely use this set of characters as part of the group name, but now it is forbidden.
On existing systems, the group names that contain the now reserved delimiter will be renamed, with sets of double colons being replaced by a dash (`-`) during the migration process.
Additionally, existing custom group object attributes named *name_last* and *parent_id* will be renamed too, by adding an underscore in front (*_name_last* and *_parent_id*). This is due to these attributes now being part of the group model, requiring dedicated table columns under the reserved names.
### Disallowed URL Values in User's Name Attributes
Text values that resemble valid URI addresses are now disallowed for user's first and last name attributes. Previously, it was possible to save any text in these attributes. The administrators should take a note of this change since it can have an impact on existing user data.
No migration will be run for existing users on update to Zammad 6.2. In case there are user records that contain URLs in their name attributes, they will be sanitized during subsequent updates. No manual action from administrator will be required, as the URI scheme or protocol will be automatically stripped from offending values.
## 6.1
### New Organization Attribute `vip`
Zammad 6.1 creates a new `vip` attribute for organizations. For systems with previously created `vip` attributes, there is special caution needed. In case of `boolean` attributes, Zammad will adjust them to the new settings and keep using them. For attributes of other types, Zammad will rename the existing attribute to `_vip` and add a new `vip` boolean attribute. This may cause issues if the previous attribute was used in other parts of the system, e.g. Triggers. In such cases, the relevant configurations must be reviewed and adjusted.
### Support for the Unicorn Web Server is removed
It seems that the Unicorn Web Server is no longer really used with Zammad. Therefore, we have decided to remove it with Zammad 6.1 after it was deprecated in Zammad 6.0.
## 6.0
### nginx configuration update
When updating to Zammad 6.0 from a previous version, the system administrator needs to add some content to the configuration of the reverse proxy.
Example for Apache:
```apache
ProxyPass /cable ws://127.0.0.1:3000/cable
```
Example for nginx:
```nginx
location /cable {
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_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_pass http://zammad-railsserver;
}
```
This is required to enable the new Rails Action Cable based realtime communication. You can find more details about this change at [this github commit](https://github.com/zammad/zammad/commit/d873b2a04bd172b90573170bc2d1d4ee75a47f02).
### Mandatory Redis Dependency
Starting with Zammad version 6.0, Redis is required to run Zammad. For package-based installations, the new dependency is automatically installed in the system during the update.
Note: Hosted customers do not need to take any further action regarding this change!
### Health Check API Changes
Starting with Zammad 6.0, the "health check" monitoring API at `/api/v1/monitoring/health_check` will no longer echo the used authentication token in the response payload.
### Excel Export Format Change
Starting with Zammad version 6.0, all Excel exports will be exported exclusively in xlsx format. The original xls format will no longer be supported.
### Storage location of unprocessable/oversized emails
Zammad stores emails that were unprocessable or rejected due to size constraints in the file system. The location of such emails changed from `tmp/unprocessable_mail` and `tmp/oversized_mail` to `var/spool/unprocessable_mail` and `var/spool/oversized_mail` within the `/opt/zammad` directory. Existing emails are automatically moved to the new location.
### Naming change in Token model and EmailAddress model
The `token` model is used to store access tokens and had field names which may have caused confusion for developers using them. Therefore, the field previously called `name:` now has the correct identifier `token:` (as it stores the actual token value), and the field previously called `label:` is now called `name:` for better consistency with other models. The `EmailAddress` model is used to information about email addresses Zammad receives mail for. For consistency reasons, its `realname:` field is now called `name:`.
This means that the attribute via the REST API also changes: from token or realname to "name".
### Docker image zammad-docker was archived
The repository [zammad-docker](https://github.com/zammad/zammad-docker) was intended for testing / development purposes only. This repository was archived and will receive no further updates. Please use [zammad-docker-compose](https://github.com/zammad/zammad-docker-compose) instead.

View File

@@ -1,7 +1,7 @@
# Change Log for Zammad 6.5.x
# Change Log for Zammad 6.5.0
- [Release notes](https://zammad.com/en/releases/6-5-x)
- [Release notes](https://zammad.com/en/releases/6-5)
- [Implemented enhancements](https://github.com/zammad/zammad/issues?q=is%3Aclosed+milestone%3A6.5+(-type%3ABug+AND+-label%3Abug))
- [Closed bugs](https://github.com/zammad/zammad/issues?q=is%3Aclosed+milestone%3A6.5+(type%3ABug+OR+label%3Abug))
- [Full commit log](https://github.com/zammad/zammad/compare/6.4.1...6.5.x)
- [File tree](https://github.com/zammad/zammad/tree/6.5.x)
- [Full commit log](https://github.com/zammad/zammad/compare/6.4.2...6.5.0)
- [File tree](https://github.com/zammad/zammad/tree/6.5.0)

View File

@@ -1,9 +1,9 @@
FROM node:20-bookworm-slim AS node
RUN npm -g install pnpm
RUN npm -g install corepack && corepack enable pnpm
RUN rm /usr/local/bin/yarn /usr/local/bin/yarnpkg
FROM ruby:3.2.7-slim-bookworm AS builder
FROM ruby:3.2.8-slim-bookworm AS builder
ARG DEBIAN_FRONTEND=noninteractive
ARG RAILS_ENV=production
ARG ZAMMAD_DIR=/opt/zammad
@@ -17,7 +17,7 @@ RUN contrib/docker/setup.sh builder
# note: zammad is currently incompatible to alpine because of:
# https://github.com/docker-library/ruby/issues/113
FROM ruby:3.2.7-slim-bookworm
FROM ruby:3.2.8-slim-bookworm
ARG DEBIAN_FRONTEND=noninteractive
ARG ZAMMAD_USER=zammad
ENV RAILS_ENV=production

View File

@@ -3,7 +3,7 @@
source 'https://rubygems.org'
# core - base
ruby '3.2.7'
ruby '3.2.8'
gem 'rails', '~> 7.2.0'
# core - rails additions

View File

@@ -164,7 +164,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cbor (0.5.9.8)
cgi (0.4.1)
cgi (0.4.2)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
@@ -278,7 +278,7 @@ GEM
ostruct
globalid (1.2.1)
activesupport (>= 6.1)
graphql (2.4.10)
graphql (2.4.13)
base64
fiber-storage
logger
@@ -326,7 +326,7 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.10.1)
json (2.10.2)
json-jwt (1.16.7)
activesupport (>= 4.2)
aes_key_wrap
@@ -400,7 +400,7 @@ GEM
net-protocol
nio4r (2.7.4)
nkf (0.2.0)
nokogiri (1.18.3)
nokogiri (1.18.5)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nori (2.7.1)
@@ -527,7 +527,7 @@ GEM
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
racc (1.8.1)
rack (2.2.11)
rack (2.2.13)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-oauth2 (2.2.1)
@@ -663,7 +663,7 @@ GEM
hashie (~> 5.0.0)
httparty (~> 0.21.0)
ruby-progressbar (1.13.0)
ruby-saml (1.17.0)
ruby-saml (1.18.0)
nokogiri (>= 1.13.10)
rexml
rubyntlm (0.6.5)
@@ -769,7 +769,7 @@ GEM
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.2)
uri (1.0.3)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
@@ -966,7 +966,7 @@ DEPENDENCIES
zendesk_api
RUBY VERSION
ruby 3.2.7p253
ruby 3.2.8p263
BUNDLED WITH
2.4.1

View File

@@ -1 +1 @@
6.5.x
6.5.0

View File

@@ -261,7 +261,7 @@ class ChannelGroupEdit extends App.ControllerModal
content: =>
configureAttributesBase = [
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
]
@form = new App.ControllerForm(
@@ -392,7 +392,7 @@ class ChannelEmailAccountWizard extends App.ControllerWizardModal
{ name: 'email', display: __('Email'), tag: 'input', type: 'email', limit: 120, null: false, placeholder: 'support@example.com', autocapitalize: false, autocomplete: 'off' },
{ name: 'password', display: __('Password'), tag: 'input', type: 'password', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', single: true },
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', null: false, options: @emailAddressOptions(@channel?.id, @channel?.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', null: false, options: @emailAddressOptions(@channel?.id, @channel?.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
]
@formMeta = new App.ControllerForm(
@@ -401,6 +401,7 @@ class ChannelEmailAccountWizard extends App.ControllerWizardModal
configure_attributes: configureAttributesBase
className: ''
params: @account.meta
handlers: [@destinationGroupEmailAddressFormHandler()]
)
# outbound
@@ -422,7 +423,7 @@ class ChannelEmailAccountWizard extends App.ControllerWizardModal
initializeInboundForm: (params) =>
configureAttributesInbound = [
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', null: false, options: @emailAddressOptions(@channel?.id, @channel?.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', null: false, options: @emailAddressOptions(@channel?.id, @channel?.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'adapter', display: __('Type'), tag: 'select', multiple: false, null: false, options: @channelDriver.email.inbound, translate: true },
{ name: 'options::host', display: __('Host'), tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false },
{ name: 'options::user', display: __('User'), tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autocomplete: 'off' },
@@ -753,6 +754,7 @@ class ChannelEmailAccountWizard extends App.ControllerWizardModal
className: ''
handlers: [
App.FormHandlerChannelAccountArchiveMode.run
App.FormHandlerChannelAccountArchiveBefore.run
]
params: options
)

View File

@@ -59,6 +59,7 @@ class App.ChannelInboundEmailArchive extends App.ControllerModal
className: ''
handlers: [
App.FormHandlerChannelAccountArchiveMode.run
App.FormHandlerChannelAccountArchiveBefore.run
]
attributePrefix: 'options::'
params:

View File

@@ -0,0 +1,17 @@
class App.FormHandlerChannelAccountArchiveBefore
@run: (params, attribute, attributes, classname, form, ui) ->
attributePrefix = ui.attributePrefix || ''
return if attribute.name isnt "#{attributePrefix}archive" and attribute.name isnt "#{attributePrefix}archive_before"
archive = !!(params.options?.archive or params.archive)
archiveBefore = params.options?.archive_before or params.archive_before
isAlertVisible = new Date(archiveBefore) > Date.now() if archiveBefore
isAlertVisible = false if not archive
setTimeout(->
$(form).closest('.modal-body,.wizard-body')
.find('.js-archiveBeforeAlert')
.toggleClass('hide', not isAlertVisible)
0
)

View File

@@ -279,7 +279,7 @@ class ChannelInboundEdit extends App.ControllerModal
content: =>
configureAttributesBase = [
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'options::folder', display: __('Folder'), tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, placeholder: __('optional') },
{ name: 'options::keep_on_server', display: __('Keep messages on server'), tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false },
]
@@ -380,7 +380,7 @@ class ChannelGroupEdit extends App.ControllerModal
content: =>
configureAttributesBase = [
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
]
@form = new App.ControllerForm(
model:

View File

@@ -32,7 +32,7 @@ class App.ChannelMicrosoft365 extends App.ControllerTabs
@alert = {
variant: 'info'
message: App.i18n.translateContent('Compared to the Microsoft 365 Graph API Email Channel, this is the traditional implementation using OAuth and IMAP. When setting up new channels, we suggest using the Graph API implementation instead. When you think there are good reasons to keep the IMAP approach as an alternative in the future, please let us know in the Zammad Community %l. More information can be found here %l.', 'https://community.zammad.org/t/microsoft-365-email-graphapi-vs-imap/16892', 'https://admin-docs.zammad.org/en/latest/channels/microsoft365/index.html')
message: App.i18n.translateContent('Compared to the Microsoft 365 Graph API Email Channel, this is the traditional implementation using OAuth and IMAP. When setting up new channels, we suggest using the Graph API implementation instead. More information can be found here %l.', 'https://admin-docs.zammad.org/en/latest/channels/microsoft365/index.html')
}
@render()
@@ -301,7 +301,7 @@ class ChannelInboundEdit extends App.ControllerModal
content: =>
configureAttributesBase = [
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'options::folder', display: __('Folder'), tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false },
{ name: 'options::keep_on_server', display: __('Keep messages on server'), tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false },
]
@@ -407,7 +407,7 @@ class ChannelGroupEdit extends App.ControllerModal
content: =>
configureAttributesBase = [
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
]
@form = new App.ControllerForm(
model:

View File

@@ -268,7 +268,7 @@ class ChannelGroupEdit extends App.ControllerModal
content: =>
configureAttributesBase = [
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
]
@form = new App.ControllerForm(
model:
@@ -478,7 +478,7 @@ class ChannelInboundEdit extends App.ControllerModal
configureAttributesBase = [
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
{ name: 'options::folder_id', display: __('Folder'), tag: 'tree_select', null: true, options: @folderOptions, nulloption: true, default: '', help: __('Specify which folder to fetch from, or leave empty to fetch from ||inbox||.') },
{ name: 'options::keep_on_server', display: __('Keep messages on server'), tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false },
]

View File

@@ -1,5 +1,5 @@
class System extends App.ControllerTabs
@requiredPermission: 'admin.setting_system'
@requiredPermission: 'admin.system'
header: __('System')
constructor: ->
super
@@ -16,4 +16,4 @@ class System extends App.ControllerTabs
@tabs.push { name: __('Frontend'), 'target': 'ui', controller: App.SettingsArea, params: { area: 'System::UI' } }
@render()
App.Config.set('SettingSystem', { prio: 1400, parent: '#settings', name: __('System'), target: '#settings/system', controller: System, permission: ['admin.setting_system'] }, 'NavBarAdmin')
App.Config.set('SettingSystem', { prio: 1400, parent: '#settings', name: __('System'), target: '#settings/system', controller: System, permission: ['admin.system'] }, 'NavBarAdmin')

View File

@@ -8,5 +8,9 @@ class CollectionAssets extends App.Controller
App.Group.subscribe(->)
App.TicketState.subscribe(->)
App.TicketPriority.subscribe(->)
if App.Session.get().permission('ticket.agent')
App.Template.subscribe(->)
App.ChecklistTemplate.subscribe(->)
App.TicketSharedDraftStart.subscribe(->)
App.Config.set('collection_assets', CollectionAssets, 'Plugins')

View File

@@ -63,8 +63,9 @@ class Modal extends App.ControllerModal
@sent = true
@update()
error: =>
@contentInline = App.i18n.translateContent('Verification email could not be sent.')
error: (data, status, xhr) =>
details = data.responseJSON || {}
@contentInline = details.error_human || details.error || __('Verification email could not be sent.')
@update()
App.Config.set('user_signup', UserSignupCheck, 'Plugins')

View File

@@ -93,6 +93,13 @@ class App.UiElement.ApplicationUiElement
else
@getConfigOptionList(attribute)
@getRelationOptionListSearchParams: (attribute) ->
result =
sortBy: attribute.sortBy
if attribute.filter && _.isArray(attribute.filter) && attribute.tag is 'select'
result.translate = attribute.translate
result
@getRelationOptionList: (attribute, params) ->
# build options list based on relation
@@ -101,6 +108,7 @@ class App.UiElement.ApplicationUiElement
attribute.options = []
list = []
searchParams = @getRelationOptionListSearchParams(attribute)
if attribute.filter
App.Log.debug 'ControllerForm', '_getRelationOptionList:filter', attribute.filter
@@ -109,7 +117,7 @@ class App.UiElement.ApplicationUiElement
if typeof attribute.filter is 'function'
App.Log.debug 'ControllerForm', '_getRelationOptionList:filter-function'
all = App[ attribute.relation ].search(sortBy: attribute.sortBy)
all = App[ attribute.relation ].search(searchParams)
list = attribute.filter(all, 'collection', params)
@@ -120,7 +128,7 @@ class App.UiElement.ApplicationUiElement
App.Log.debug 'ControllerForm', '_getRelationOptionList:filter-data', filter
# check all records
for record in App[ attribute.relation ].search(sortBy: attribute.sortBy)
for record in App[ attribute.relation ].search(searchParams)
# check all filter attributes
for key in filter
@@ -139,7 +147,7 @@ class App.UiElement.ApplicationUiElement
filter.push(params[ attribute.name ])
# check all records
for record in App[ attribute.relation ].search(sortBy: attribute.sortBy, translate: attribute.translate)
for record in App[ attribute.relation ].search(searchParams)
# check all filter attributes
for key in filter
@@ -152,10 +160,10 @@ class App.UiElement.ApplicationUiElement
# no data filter matched
else
App.Log.debug 'ControllerForm', '_getRelationOptionList:filter-data no filter matched'
list = App[ attribute.relation ].search(sortBy: attribute.sortBy)
list = App[ attribute.relation ].search(searchParams)
else
App.Log.debug 'ControllerForm', '_getRelationOptionList:filter-no filter defined'
list = App[ attribute.relation ].search(sortBy: attribute.sortBy)
list = App[ attribute.relation ].search(searchParams)
# Turn on attribute translation if configured for the relation object.
if 'configure_translate' in App[attribute.relation]
@@ -163,9 +171,23 @@ class App.UiElement.ApplicationUiElement
App.Log.debug 'ControllerForm', '_getRelationOptionList', attribute, list
if @isTreeRelation(attribute)
@setTreeRelationData(list, attribute)
# build options list
@buildOptionList(list, attribute)
@hasActiveChildren: (attribute, children, filter = undefined) ->
return false if !children
for row in children
return true if row.active && (!filter || _.contains(filter, row.id.toString()))
childrenActive = @hasActiveChildren(attribute, attribute.tree_children[row.id.toString()], filter)
return true if childrenActive
return false
@buildOptionListRow: (attribute, item) ->
# check if element is selected, show it anyway - ignore active state
@@ -176,7 +198,7 @@ class App.UiElement.ApplicationUiElement
hasActiveChildren = false
if @isTreeRelation(attribute)
hasActiveChildren = _.some(item.all_children(), (obj) -> obj.active)
hasActiveChildren = @hasActiveChildren(attribute, attribute.tree_children[item.id.toString()])
# if active or if active doesn't exist
return if !item.active && activeSupport && !isSelected && !hasActiveChildren
@@ -361,3 +383,12 @@ class App.UiElement.ApplicationUiElement
return false if !attribute.relation
return false if !_.find(App[attribute.relation].configure_attributes, (attr) -> attr.name is 'parent_id')
return true
@setTreeRelationData: (list, attribute) ->
tree_children = {}
for row in list
parent_id = row.parent_id?.toString() || '-NONE-'
tree_children[parent_id] ||= []
tree_children[parent_id].push(row)
attribute.tree_children = tree_children

View File

@@ -56,8 +56,7 @@ class App.UiElement.ApplicationTreeSelect extends App.UiElement.ApplicationUiEle
activeChildren = false
if option.value && option.children && option.children.length > 0
if @isTreeRelation(attribute)
all_children_ids = _.map(App[attribute.relation].find(option.value).all_children(), (group) -> group.id.toString())
if _.intersection(attribute.filter, all_children_ids).length > 0
if @hasActiveChildren(attribute, attribute.tree_children[option.value], attribute.filter)
activeChildren = true
else
for value in attribute.filter
@@ -81,18 +80,18 @@ class App.UiElement.ApplicationTreeSelect extends App.UiElement.ApplicationUiEle
@buildOptionList: (list, attribute) ->
return super if !@isTreeRelation(attribute)
parents = list.filter((obj) -> !obj.parent_id)
attribute.options = @buildOptionListTreeRelation(list, attribute, parents)
attribute.options = @buildOptionListTreeRelation(attribute, '-NONE-')
attribute.sortBy = null
@buildOptionListTreeRelation: (list, attribute, entries) ->
@buildOptionListTreeRelation: (attribute, parent_id) ->
result = []
for item in entries
return result if !attribute.tree_children[parent_id]
for item in attribute.tree_children[parent_id]
row = @buildOptionListRow(attribute, item)
continue if !row
children = _.filter(list, (obj) -> obj.parent_id is item.id)
children = @buildOptionListTreeRelation(list, attribute, children)
children = @buildOptionListTreeRelation(attribute, item.id.toString())
if children.length > 0
row.children = children

View File

@@ -297,6 +297,7 @@ class GettingStartedChannelEmail extends App.ControllerWizardFullScreen
className: ''
handlers: [
App.FormHandlerChannelAccountArchiveMode.run
App.FormHandlerChannelAccountArchiveBefore.run
]
)

View File

@@ -29,6 +29,13 @@ class App.KnowledgeBaseSidebarAttachments extends App.Controller
attachments: @object.attachments
)
@renderDragAndDropIfNeeded()
renderDragAndDropIfNeeded: ->
return if @dragAndDropRendered
@dragAndDropRendered = true
html5Upload.initialize(
uploadUrl: @object.generateURL('attachments')
dropContainer: @el.get(0)

View File

@@ -69,6 +69,7 @@ class PasswordReset extends App.ControllerFullPage
data: JSON.stringify(params)
processData: true
success: @success
error: @error
)
success: (data) =>
@@ -82,4 +83,13 @@ class PasswordReset extends App.ControllerFullPage
public_links: public_links
))
error: (data, statusText, error) =>
@formEnable(@form.el)
details = data.responseJSON || {}
@notify(
type: 'error'
msg: details.error_human || details.error || __('Loading failed.')
)
App.Config.set('password_reset', PasswordReset, 'Routes')

View File

@@ -22,6 +22,7 @@ class App.Search extends App.Controller
@savedOrderBy = {}
@resultPaginated = {}
@result = {}
@queue = {}
current = App.TaskManager.get(@taskKey).state
if current && current.query
@@ -67,19 +68,34 @@ class App.Search extends App.Controller
show: (params) =>
if @table
@table.show()
@navupdate(url: '#search', type: 'menu')
if !_.isEmpty(params.query)
if !_.isEmpty(params.query) # When opening detailed search from the global search
@$('.js-search').val(params.query).trigger('keyup')
return
if @query
@search(500, true)
else if @query # When coming back to detailed search taskbar from another taskbar
@reloadCurrentSearch()
hide: ->
if @table
@table.hide()
reloadCurrentSearch: =>
if !_.isEmpty(@getSavedOrderBy())
modelsToLoad = _.keys(@savedOrderBy)
modelsToLoad = _.without(modelsToLoad, @model)
modelsToLoad.push('all')
@queue = { query: @query, models: modelsToLoad }
@goToPaginated(@model, @getSavedOrderBy().page)
else
modelsToLoad = _.keys(@savedOrderBy)
@queue = { query: @query, models: modelsToLoad }
@search(-1, true)
changed: ->
# nothing
@@ -148,7 +164,7 @@ class App.Search extends App.Controller
@delayedRemoveAnyPopover()
search: (delay, force = false) =>
search: (delay, force = false, skipRendering = false) =>
query = @searchInput.val().trim()
if !force
return if !query
@@ -164,8 +180,9 @@ class App.Search extends App.Controller
delay = 200
@globalSearch.search(
delay: delay
query: @query
delay: delay
query: @query
skipRendering: skipRendering
)
buildResultCacheKey: (offset, direction, column, object) -> {
@@ -174,26 +191,49 @@ class App.Search extends App.Controller
renderResult: (result = {}, params = undefined) =>
if !_.isUndefined(params?.offset)
@renderPaginatedSearchResult(result, params)
else
@renderInitialSearchResult(result, params)
for klassName, metadata of result
@resultPaginated[klassName] ||= {}
@loadNextInQueue()
cacheKey = @buildResultCacheKey(params?.offset, params?.orderDirection, params?.orderBy, klassName)
@resultPaginated[klassName][cacheKey] = metadata.items
renderPaginatedSearchResult: (result, params) =>
for klassName, metadata of result
@resultPaginated[klassName] ||= {}
if @model is klassName
@renderTab(klassName, metadata.items || [])
cacheKey = @buildResultCacheKey(params?.offset, params?.orderDirection, params?.orderBy, klassName)
@resultPaginated[klassName][cacheKey] = metadata.items
return
@result[klassName] ||= {}
@result[klassName].total_count = metadata.total_count
if @model is klassName
@renderTab(klassName, metadata.items || [])
renderInitialSearchResult: (result, params) =>
@result = result
# @savedOrderBy = {}
for tab in @tabs
count = result[tab.model]?.total_count || 0
@$(".js-tab#{tab.model} .js-counter").text(count)
if @model is tab.model
if !params?.skipRendering and @model is tab.model
@renderTab(tab.model, result[tab.model]?.items || [])
loadNextInQueue: =>
if @queue?.query != @query
@queue = {}
return
nextModel = @queue.models.shift()
if !nextModel
@queue = {}
else if nextModel is 'all'
@search(-1, true, true)
else
@goToPaginated(nextModel, @savedOrderBy[nextModel]?.page)
showTab: (e) =>
tabs = $(e.currentTarget).closest('.tabs')
tabModel = $(e.currentTarget).data('tab-content')
@@ -289,12 +329,11 @@ class App.Search extends App.Controller
'click': callbackCheckbox
select_all: callbackCheckbox
sortClickCallback: @saveOrderBy
pagerAjax: true
)
updateSearch = =>
callback = =>
@search(0, true)
@delay(callback, 100)
@delay(@reloadCurrentSearch, 100)
@bulkForm.releaseController() if @bulkForm
@bulkForm = new App.TicketBulkForm(
@@ -352,20 +391,20 @@ class App.Search extends App.Controller
events:
'click': openObject
sortClickCallback: @saveOrderBy
pagerEnabled: false
orderEnabled: false
pagerAjax: true
)
@renderPagination()
renderPagination: =>
(@table.table || @table).pagerEnabled = false
(@table.table || @table).orderEnabled = false
object = @el.find('.js-tab.active').data('tab-content')
page = @getSavedOrderBy()?.page || 0
count = @result[object]?.total_count || 0
pages = Math.ceil(count / 50) - 1
if !pages
if (!pages && !page) || count == 0
@$('.js-pager').html('')
return
@@ -415,9 +454,10 @@ class App.Search extends App.Controller
@globalSearch.search(
query: @query
object:object
offset: page * 50
offset: (page || 0) * 50
orderBy: savedOrder?.orderBy
orderDirection: savedOrder?.orderDirection
delay: -1
)
updateTask: =>

View File

@@ -309,7 +309,11 @@ class App.FormHandlerCoreWorkflow
# this will use the form handler information to send the data to the backend via ajax/websockets
@request: (classname, form, ui, attributes, params) ->
requestID = "CoreWorkflow-#{Math.floor( Math.random() * 999999 ).toString()}"
coreWorkflowRequests = {}
for key, value of coreWorkflowRequests
continue if ui.idPrefix isnt value.ui.idPrefix
delete coreWorkflowRequests[key]
coreWorkflowRequests[requestID] = { classname: classname, form: form, ui: ui, attributes: attributes, params: params }
requestData = {

View File

@@ -6,7 +6,8 @@ class App.SidebarChecklistStart extends App.Controller
constructor: ->
super
@subscribeId = App.ChecklistTemplate.subscribe(@render, initFetch: true)
@subscribeId = App.ChecklistTemplate.subscribe(@render)
@render()
release: =>
App.ChecklistTemplate.unsubscribe(@subscribeId)

View File

@@ -47,9 +47,11 @@ class App.UserProfileActionRow extends App.ControllerObserverActionRow
msg: App.i18n.translateContent('Email sent to "%s". Please let the user verify their email account.', user.email)
removeAll: true
error: (data, status, xhr) =>
details = data.responseJSON || {}
@notify
type: 'error'
msg: App.i18n.translateContent('Failed to send email to "%s". Please contact an administrator.', user.email)
msg: details.error_human || details.error || __('Failed to send email to "%s". Please contact an administrator.', user.email)
removeAll: true
)

View File

@@ -194,9 +194,9 @@ PreDefinedWebhookMixin =
# Make a deep clone of the pre-defined webhook field definition.
attrs = $.extend(true, [], App[@genericObject].configure_attributes)
# Process edit forms conditionally, in case we are dealing with a pre-defined webhook.
# Process edit and clone forms conditionally, in case we are dealing with a pre-defined webhook.
if not @preDefinedWebhook and @item?.pre_defined_webhook_type
@preDefinedWebhook = App.PreDefinedWebhook.find(@item.pre_defined_webhook_type)
@preDefinedWebhook = App.PreDefinedWebhook.findByAttribute(name: @item.pre_defined_webhook_type)
# Add pre-defined webhook fields as additional attributes.
if @preDefinedWebhook
@@ -212,6 +212,12 @@ PreDefinedWebhookMixin =
{ configure_attributes: attrs }
# Inject the pre-defined webhook data into the edit and clone form.
contentFormParams: ->
return if not @item
$.extend(true, @item, { custom_payload: @preDefinedWebhook?.custom_payload if not @item.customized_payload })
WebhookSslVerifyAlertMixin =
events:
'change select[name="ssl_verify"]': 'handleSslVerifyAlert'
@@ -259,11 +265,20 @@ class EditWebhook extends App.ControllerGenericEdit
setTimeout (=> @handleSslVerifyAlert()), 0
# Inject the pre-defined webhook data into the form.
contentFormParams: ->
$.extend(true, @item, { custom_payload: @preDefinedWebhook?.custom_payload if not @item.customized_payload })
class NewWebhook extends App.ControllerGenericNew
@include PreDefinedWebhookMixin
@include WebhookSslVerifyAlertMixin
shown: false
constructor: ->
super
App.PreDefinedWebhook.subscribe(@render, initFetch: true)
render: ->
super
setTimeout (=> @handleSslVerifyAlert()), 0
App.Config.set('Webhook', { prio: 3350, name: __('Webhook'), parent: '#manage', target: '#manage/webhook', controller: Index, permission: ['admin.webhook'] }, 'NavBarAdmin')

View File

@@ -1,7 +1,6 @@
class App.WidgetSharedDraft extends App.Controller
constructor: ->
super
@load()
@subscribeId = App.TicketSharedDraftStart.subscribe(@render)
@render()
@@ -22,17 +21,6 @@ class App.WidgetSharedDraft extends App.Controller
active_draft: active_draft
)
load: =>
@ajax
id: 'shared_drafts_index'
type: 'GET'
url: @apiPath + '/tickets/shared_drafts'
processData: true
success: (data, status, xhr) =>
App.TicketSharedDraftStart.deleteAll()
App.Collection.loadAssets(data.assets)
@render()
visibleDrafts: ->
App.TicketSharedDraftStart.findAllByAttribute 'group_id', parseInt(@group_id)

View File

@@ -4,7 +4,8 @@ class App.WidgetTemplate extends App.Controller
constructor: ->
super
@subscribeId = App.Template.subscribe(@render, initFetch: true)
@subscribeId = App.Template.subscribe(@render)
@render()
release: =>
App.Template.unsubscribe(@subscribeId)

View File

@@ -3,6 +3,9 @@ class App.TicketList extends App.Controller
@registerPopovers 'Organization', 'User'
orderBy: null
orderDirection: null
pagerEnabled: false
orderEnabled: false
pagerAjax: false
constructor: ->
super
@@ -130,6 +133,9 @@ class App.TicketList extends App.Controller
bindCheckbox: @bindCheckbox
radio: @radio
sortClickCallback: @sortClickCallback
pagerEnabled: @pagerEnabled
orderEnabled: @orderEnabled
pagerAjax: @pagerAjax
)
@renderPopovers()

View File

@@ -6,6 +6,9 @@ class App.Plugin
_instance ?= new _pluginSingleton
_instance.init(el)
@instance: ->
_instance
class _pluginSingleton
backends: {}
el: undefined

View File

@@ -97,6 +97,8 @@ class App.SearchableSelect extends Spine.Controller
@clear.on('click.searchable_select', @clearValue)
@el.addClass('searchableSelect-dropdown-up') if @attribute.direction == 'up'
renderSubmenus: (options) ->
html = ''
if options
@@ -225,8 +227,6 @@ class App.SearchableSelect extends Spine.Controller
onDropdownShown: =>
@input.on('click', @stopPropagation)
@dropupSetTopOffsetIfNeeded()
@highlightFirst()
if @level > 0
@showSubmenu(@currentMenu)
@@ -462,13 +462,7 @@ class App.SearchableSelect extends Spine.Controller
duration: 240
if @attribute.direction == 'up'
adjustBy = @currentMenu.height() - target_menu.height()
if adjustBy > 0
target_menu.css('top', adjustBy)
else if adjustBy < 0
@currentMenu.css('top', adjustBy * -1)
@dropdown.css('top', "-#{target_menu.height()}px")
@dropdown.css('overflow-y', 'hidden')
@currentMenu.velocity
properties:
@@ -477,7 +471,7 @@ class App.SearchableSelect extends Spine.Controller
duration: 240
complete: =>
if @attribute.direction == 'up'
target_menu.css('top', 0)
@dropdown.css('overflow-y', 'auto')
oldCurrentItem.removeClass('is-active')
oldCurrentItem.removeClass('is-highlighted') if oldCurrentItem.hasClass('js-enter')
@@ -486,7 +480,6 @@ class App.SearchableSelect extends Spine.Controller
@dropdown.height(target_menu.height())
@currentMenu = target_menu
@animating = false
@dropupSetTopOffsetIfNeeded()
showSubmenu: (menu) ->
@currentMenu.prop('hidden', true)
@@ -672,9 +665,6 @@ class App.SearchableSelect extends Spine.Controller
else
@highlightFirst(true)
if @isOpen
@dropupSetTopOffsetIfNeeded()
addValueToShadowInput: (currentText, dataId) ->
@resetSearch()
@@ -707,11 +697,6 @@ class App.SearchableSelect extends Spine.Controller
@currentItem.removeClass('is-highlighted') if @currentItem.hasClass('js-enter')
@currentItem = null
dropupSetTopOffsetIfNeeded: =>
return if @attribute.direction != 'up'
@dropdown.css('top', "-#{@dropdown.find('ul:visible').height()}px")
toggleClear: =>
if @attribute.value and not @isOpen
@clear.removeClass('hide')

View File

@@ -2,15 +2,16 @@
App.DestinationGroupEmailAddressesMixin =
destinationGroupEmailAddressFormHandler: (item) ->
formHandler = (params, attribute, attributes, classname, form, ui) =>
return if not item?.id or attribute.name isnt 'group_id'
return if attribute.name isnt 'group_id'
return if ui.FormHandlerGroupEmailAddressDone
ui.FormHandlerGroupEmailAddressDone = true
$(form).find('[name=group_id]').off('change.group_id').on('change.group_id', (e) =>
group_id = $(e.target).val()
for attr in attributes
continue if attr.name isnt 'group_email_address_id'
attr.options = @emailAddressOptions(item.id, group_id)
attr.options = @emailAddressOptions(item?.id, group_id)
newElement = ui.formGenItem(attr, classname, form)
form.find('div.form-group[data-attribute-name="' + attr.name + '"]').replaceWith(newElement)
)
@@ -18,13 +19,14 @@ App.DestinationGroupEmailAddressesMixin =
formHandler
emailAddressOptions: (id, group_id) ->
group = App.Group.find(group_id)
if !id
return {
false: App.i18n.translatePlain('Do not change email address')
false: App.i18n.translatePlain('Do not change email address (%s)', group?.email_address?.email or '-')
true: App.i18n.translatePlain('Change to channel email address')
}
group = App.Group.find(group_id)
emailAddresses = App.EmailAddress.findAllByAttribute('channel_id', id)
_.reduce(
@@ -33,7 +35,7 @@ App.DestinationGroupEmailAddressesMixin =
return acc if emailAddress.id is group?.email_address_id
acc[emailAddress.id] = App.i18n.translatePlain('Change to %s', emailAddress.email)
acc
{ false: App.i18n.translatePlain('Do not change email address') }
{ false: App.i18n.translatePlain('Do not change email address (%s)', group?.email_address?.email or '-') }
)
processDestinationGroupEmailAddressParams: (params) ->

View File

@@ -50,16 +50,6 @@ class App.Group extends App.Model
signature_id_is_display_warning: (signature_id) ->
!App.Signature.find(signature_id).active
all_children: ->
result = []
check_next = [@]
while check_next.length > 0
parent_ids = _.map(check_next, (group) -> group.id)
children = _.filter(App.Group.all(), (group) -> _.contains(parent_ids, group.parent_id))
result = result.concat(children)
check_next = children
result
displayName: =>
name = @name || @name_last || '-'
name.replaceAll('::', ' › ')

View File

@@ -111,6 +111,9 @@
<div class="modal-body">
<div class="wizard-body vertical justified">
<div class="alert alert--danger hide" role="alert"></div>
<div class="alert alert--warning js-archiveBeforeAlert hide" role="alert">
<%- @T('You have selected a cut-off time in the future. Be aware that all emails (including future ones) are going to be archived until the selected time is reached.') %>
</div>
<p class="js-messageFound"></p>
<p><%- @T('You can import some of your emails as an "archive", which means that no notifications are sent and the tickets will be in a target state that you define.') %></p>
<p><%- @T('You can find archived emails in Zammad anytime using the search function, like for any other ticket.') %></p>

View File

@@ -1,5 +1,8 @@
<div>
<div class="alert alert--danger hide" role="alert"></div>
<div class="alert alert--warning js-archiveBeforeAlert hide" role="alert">
<%- @T('You have selected a cut-off time in the future. Be aware that all emails (including future ones) are going to be archived until the selected time is reached.') %>
</div>
<% if @content_messages: %>
<p><%- @T('%s email(s) were found in your mailbox. They will all be moved from your mailbox into Zammad.', @content_messages) %></p>
<% end %>

View File

@@ -1,5 +1,6 @@
<p><%- @T(@explanation) %></p>
<div class="js-pager"></div>
<table class="table table--placeholder">
<thead><tr><th><%- @T('No Entries') %>
</table>
</table>

View File

@@ -1,15 +1,21 @@
<div class="horizontal" role="group" aria-label="<%- @T('Pagination links') %>" style="margin-bottom: 5px;">
<a href="#" class="btn btn--split--first btn--slim btn--small btn--secondary js-page<% if @page <= 0: %> is-disabled<% end %>" data-page="<%= @page - 1 %>" aria-label="<%- @T('Previous page') %>"<% if @page <= 0: %> aria-disabled="true"<% end %>>&lsaquo;</a>
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is 0: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" data-page="0" aria-label="<%- @T('Page %s', 1) %>">1</a>
<% stepper = 2 %>
<% stepper = 3 if @page is 1 || @page is @pages - 1 %>
<% stepper = 4 if @page is 0 || @page is @pages %>
<% if @pages > 1: %>
<% for page in [1..@pages - 1]: %>
<% continue if page > @page + stepper || page < @page - stepper %>
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is page: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" aria-label="<%- @T('Page %s', page + 1) %>" aria-current="true" data-page="<%= page %>"><% if page is @page - stepper || page is @page + stepper: %>&hellip;<% else: %><%= page + 1 %><% end %></a>
<% stepper = 2 %>
<% stepper = 3 if @page is 1 || @page is @pages - 1 %>
<% stepper = 4 if @page is 0 || @page is @pages %>
<% if @pages > 1: %>
<% for page in [1..@pages - 1]: %>
<% continue if page > @page + stepper || page < @page - stepper %>
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is page: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" aria-label="<%- @T('Page %s', page + 1) %>" aria-current="true" data-page="<%= page %>"><% if page is @page - stepper || page is @page + stepper: %>&hellip;<% else: %><%= page + 1 %><% end %></a>
<% end %>
<% end %>
<% end %>
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is @pages: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" data-page="<%= @pages %>" aria-label="<%- @T('Page %s', @pages + 1) %>"><%= @pages + 1 %></a>
<% if @pages > 0: %>
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is @pages: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" data-page="<%= @pages %>" aria-label="<%- @T('Page %s', @pages + 1) %>"><%= @pages + 1 %></a>
<% end %>
<a href="#" class="btn btn--split--last btn--slim btn--small btn--secondary js-page<% if @page >= @pages: %> is-disabled<% end %>" data-page="<%= @page + 1 %>" aria-label="<%- @T('Next page') %>"<% if @page >= @pages: %> aria-disabled="true"<% end %>>&rsaquo;</a>
</div>

View File

@@ -84,8 +84,8 @@
<h2><%- @T('Archive Emails') %></h2>
<div class="wizard-body vertical justified">
<div class="alert alert--danger hide" role="alert"></div>
<div class="alert alert--warning js-sslVerifyAlert hide" role="alert">
<%- @T('Turning off SSL verification is a security risk and should be used only temporary. Use this option at your own risk!') %>
<div class="alert alert--warning js-archiveBeforeAlert hide" role="alert">
<%- @T('You have selected a cut-off time in the future. Be aware that all emails (including future ones) are going to be archived until the selected time is reached.') %>
</div>
<p class="js-messageFound"></p>
<p><%- @T('You can import some of your emails as an "archive", which means that no notifications are sent and the tickets will be in a target state that you define.') %></p>

View File

@@ -101,15 +101,15 @@
</div>
<div class="action-controls">
<% if channel.active is true: %>
<div class="btn js-disable"><%- @T('Disable') %></div>
<% else: %>
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
<% end %>
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
<% if channel.options.backup_imap_classic: %>
<div class="btn btn--secondary js-rollbackMigration"><%- @T('Rollback migration') %></div>
<% end %>
<% if channel.active is true: %>
<div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
<% else: %>
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
<% end %>
<div class="btn btn--secondary js-reauthenticate"><%- @T('Reauthenticate') %></div>
</div>
</div>

View File

@@ -111,15 +111,15 @@
</div>
<div class="action-controls">
<% if channel.active is true: %>
<div class="btn js-disable"><%- @T('Disable') %></div>
<% else: %>
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
<% end %>
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
<% if channel.options.backup_imap_classic: %>
<div class="btn btn--secondary js-rollbackMigration"><%- @T('Rollback migration') %></div>
<% end %>
<% if channel.active is true: %>
<div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
<% else: %>
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
<% end %>
<div class="btn btn--secondary js-reauthenticate"><%- @T('Reauthenticate') %></div>
</div>
</div>

View File

@@ -9546,6 +9546,18 @@ li.dropdown-header {
}
}
.dropdown.searchableSelect-dropdown-up > .dropdown-menu {
top: auto;
overflow-y: auto;
bottom: 60px;
max-height: calc(100vh - 60px);
.dropdown-submenu {
top: auto;
bottom: 0;
}
}
.dropdown-title {
flex-shrink: 1;
overflow: hidden;

View File

@@ -53,7 +53,9 @@ module ApplicationController::Authenticates
raise Exceptions::Forbidden, 'API password access disabled!'
end
auth = Auth.new(username, password)
# Disable 2FA for iCal and calendar subscriptions
only_verify_password = %w[/ical /calendar_subscriptions].any? { |path| request.path.start_with?(path) }
auth = Auth.new(username, password, only_verify_password:)
begin
auth.valid!

View File

@@ -42,6 +42,11 @@ class ApplicationController::HasDownload::DownloadFile < SimpleDelegator
def file_content_type
@file_content_type ||= preferences['Content-Type'] || preferences['Mime-Type'] || ActiveStorage.binary_content_type
# Workaround for https://github.com/rails/rails/issues/51842
return @file_content_type if @file_content_type.start_with?('multipart/byteranges')
@file_content_type.split(';').first
end
def content_inline?

View File

@@ -24,7 +24,7 @@ module ApplicationController::HasUser
def request_header_from
@request_header_from ||= begin
if request.headers['X-On-Behalf-Of'].present?
ActiveSupport::Deprecation.warn("Header 'X-On-Behalf-Of' is deprecated. Please use header 'From' instead.")
ActiveSupport::Deprecation.send(:warn, "Header 'X-On-Behalf-Of' is deprecated. Please use header 'From' instead.") # rubocop:disable Zammad/DetectTranslatableString
end
request.headers['From'] || request.headers['X-On-Behalf-Of']

View File

@@ -49,7 +49,7 @@ curl http://localhost/api/v1/groups -v -u #{login}:#{password}
=end
def index
model_index_render(Group.sorted, params)
model_index_render(GroupPolicy::Scope.new(UserInfo.current_user, Group).resolve.sorted, params)
end
=begin
@@ -70,7 +70,7 @@ curl http://localhost/api/v1/groups/#{id} -v -u #{login}:#{password}
=end
def show
model_show_render(Group, params)
model_show_render(GroupPolicy::Scope.new(UserInfo.current_user, Group).resolve, params)
end
=begin

View File

@@ -21,7 +21,11 @@ class KnowledgeBase::AnswersController < KnowledgeBase::BaseController
if params[:include_contents].present?
content_ids = params[:include_contents].split(',')
contents = KnowledgeBase::Answer::Translation::Content.where id: content_ids
contents = KnowledgeBase::Answer::Translation::Content
.joins(:translation)
.where(knowledge_base_answer_translations: { answer_id: object.id })
.where(id: content_ids)
assets = ApplicationModel::CanAssets.reduce contents, assets
end

View File

@@ -411,19 +411,14 @@ curl http://localhost/api/v1/users/password_reset -v -u #{login}:#{password} -H
def password_reset_send
raise Exceptions::UnprocessableEntity, 'username param needed!' if params[:username].blank?
send = Service::User::PasswordReset::Deprecated::Send.new(username: params[:username])
begin
send.execute
rescue Service::CheckFeatureEnabled::FeatureDisabledError => e
raise Exceptions::UnprocessableEntity, e.message
rescue Service::User::PasswordReset::Send::EmailError
render json: { message: 'failed' }, status: :ok
return
end
Service::User::PasswordReset::Deprecated::Send
.new(username: params[:username])
.execute
# Result is always positive to avoid leaking of existing user accounts.
render json: { message: 'ok' }, status: :ok
rescue Service::CheckFeatureEnabled::FeatureDisabledError => e
raise Exceptions::UnprocessableEntity, e.message
end
=begin

View File

@@ -1,6 +1,5 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { useDateFormat } from '@vueuse/shared'
import { shallowRef, reactive } from 'vue'
import type { FormRef } from '#shared/components/Form/types.ts'
@@ -60,13 +59,6 @@ export const useEmailInboundMessagesForm = (
help: __(
'Emails before the cut-off time are imported as archived tickets. Emails after the cut-off time are imported as regular tickets.',
),
props: {
// With an extra hour on top, so the current selection works.
maxDate: useDateFormat(
new Date(new Date().getTime() + 60 * 60 * 1000),
'YYYY-MM-DDTHH:mm',
).value,
},
},
{
name: 'archive_state_id',

View File

@@ -0,0 +1,235 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { type Ref } from 'vue'
import {
FormHandlerExecution,
type ChangedField,
type FormHandlerFunctionData,
type FormHandlerFunctionReactivity,
type FormValues,
type FormSchemaField,
type ReactiveFormSchemaDataField,
} from '#shared/components/Form/types.ts'
import { useAlertFormHandler } from '../composables/useAlertFormHandler.ts'
import type { FormKitNode } from '@formkit/core'
const getReactivity = (
changeFields?: Ref<Record<string, Partial<FormSchemaField>>>,
fields?: Record<string, Partial<ReactiveFormSchemaDataField>>,
values?: Record<string, unknown>,
) =>
({
changeFields,
schemaData: {
fields: {
...fields,
},
...values,
},
updateSchemaDataField: () => {},
}) as unknown as FormHandlerFunctionReactivity
const getData = (changedField?: ChangedField, values?: FormValues) => {
const mockedSet = vi.fn()
const mockedRemove = vi.fn()
return {
data: {
formNode: {
store: {
set: mockedSet,
remove: mockedRemove,
},
on: vi.fn(),
} as unknown as FormKitNode,
changedField,
values,
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
} as FormHandlerFunctionData,
mockedSet,
mockedRemove,
}
}
const key = 'fieldNameWarning'
const message = { key }
const eventHandler = vi.fn()
describe('useAlertFormHandler callback', () => {
it('clears warning when callback returns false (initial)', async () => {
const { callback } = useAlertFormHandler(
'fieldName',
message,
() => false, // initialAddCallback
() => false,
eventHandler,
)
const reactivity = getReactivity(undefined, {
fieldName: {
show: true,
updateFields: false,
props: { name: 'fieldName', value: undefined },
},
})
const { data, mockedRemove, mockedSet } = getData(undefined, {
fieldName: undefined,
})
callback(FormHandlerExecution.InitialSettled, reactivity, data)
expect(mockedSet).not.toBeCalled()
expect(mockedRemove).toBeCalledWith(key)
})
it('adds warning when callback returns true (initial)', async () => {
const { callback } = useAlertFormHandler(
'fieldName',
message,
() => true, // initialAddCallback
() => false,
eventHandler,
)
const reactivity = getReactivity(undefined, {
fieldName: {
show: true,
updateFields: false,
props: { name: 'fieldName', value: undefined },
},
})
const { data, mockedRemove, mockedSet } = getData(undefined, {
fieldName: undefined,
})
callback(FormHandlerExecution.InitialSettled, reactivity, data)
expect(mockedSet).not.toBeCalledWith({
blocking: false,
key,
type: 'warning',
visible: true,
})
expect(mockedRemove).not.toBeCalled()
})
it('clears warning when callback returns false (change)', async () => {
const { callback } = useAlertFormHandler(
'fieldName',
message,
() => false,
() => false, // changeAddCallback
eventHandler,
)
const reactivity = getReactivity(undefined, {
fieldName: {
show: true,
updateFields: false,
props: { name: 'fieldName', value: undefined },
},
})
const { data, mockedRemove, mockedSet } = getData(
{
name: 'fieldName',
newValue: 'foobar',
oldValue: undefined,
},
{ fieldName: undefined },
)
callback(FormHandlerExecution.FieldChange, reactivity, data)
expect(mockedSet).not.toBeCalled()
expect(mockedRemove).toBeCalledWith(key)
})
it('adds warning when callback returns true (change)', async () => {
const { callback } = useAlertFormHandler(
'fieldName',
message,
() => false,
() => true, // changeAddCallback
eventHandler,
)
const reactivity = getReactivity(undefined, {
fieldName: {
show: true,
updateFields: false,
props: { name: 'fieldName', value: undefined },
},
})
const { data, mockedRemove, mockedSet } = getData(
{
name: 'fieldName',
newValue: 'foobar',
oldValue: undefined,
},
{ fieldName: undefined },
)
callback(FormHandlerExecution.FieldChange, reactivity, data)
expect(mockedSet).not.toBeCalledWith({
blocking: false,
key,
type: 'warning',
visible: true,
})
expect(mockedRemove).not.toBeCalled()
})
it('does not execute when another field is changed', async () => {
const { callback } = useAlertFormHandler(
'fieldName',
message,
() => false,
() => false,
eventHandler,
)
const reactivity = getReactivity(undefined, {
fieldName: {
show: true,
updateFields: false,
props: {
name: 'fieldName',
value: undefined,
},
},
anotherField: {
show: true,
updateFields: false,
props: {
name: 'anotherField',
value: undefined,
},
},
})
const { data, mockedRemove, mockedSet } = getData(
{
name: 'anotherField',
newValue: 'foobar',
oldValue: undefined,
},
{ fieldName: undefined, anotherField: 'foobar' },
)
callback(FormHandlerExecution.FieldChange, reactivity, data)
expect(mockedSet).not.toBeCalled()
expect(mockedRemove).not.toBeCalled()
})
})

View File

@@ -0,0 +1,312 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { type Ref } from 'vue'
import {
FormHandlerExecution,
type ChangedField,
type FormHandlerFunctionData,
type FormHandlerFunctionReactivity,
type FormValues,
type FormSchemaField,
} from '#shared/components/Form/types.ts'
import { useArchiveBeforeWarningHandler } from '../composables/useArchiveBeforeWarningHandler.ts'
import type { FormKitNode } from '@formkit/core'
import type { Except, SetOptional } from 'type-fest'
const getReactivity = (
changeFields?: Ref<Record<string, Partial<FormSchemaField>>>,
fields?: Record<
string,
{
show: boolean
updateFields: boolean
props: Except<
SetOptional<FormSchemaField, 'type'>,
'show' | 'props' | 'updateFields' | 'relation'
>
}
>,
values?: Record<string, unknown>,
) =>
({
changeFields,
schemaData: {
fields: {
...fields,
},
...values,
},
updateSchemaDataField: () => {},
}) as unknown as FormHandlerFunctionReactivity
const getData = (changedField?: ChangedField, values?: FormValues) => {
const mockedSet = vi.fn()
const mockedRemove = vi.fn()
return {
data: {
formNode: {
store: {
set: mockedSet,
remove: mockedRemove,
},
on: vi.fn(),
} as unknown as FormKitNode,
changedField,
values,
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
} as FormHandlerFunctionData,
mockedSet,
mockedRemove,
}
}
describe('useArchiveBeforeWarningHandler callback', () => {
it('clears warning when archive_before is missing (initial)', async () => {
const { callback } = useArchiveBeforeWarningHandler()
const reactivity = getReactivity(undefined, {
archive: {
show: true,
updateFields: false,
props: { name: 'archive', value: true },
},
archive_before: {
show: true,
updateFields: false,
props: { name: 'archive_before', value: undefined },
},
})
const { data, mockedRemove, mockedSet } = getData(undefined, {
archive: true,
archive_before: undefined,
})
callback(FormHandlerExecution.InitialSettled, reactivity, data)
expect(mockedSet).not.toBeCalled()
expect(mockedRemove).toBeCalledWith('archiveBeforeWarning')
})
it('clears warning when archive is turned off (initial)', async () => {
const { callback } = useArchiveBeforeWarningHandler()
const dateInFuture = new Date(Date.now() + 1000).toISOString()
const reactivity = getReactivity(undefined, {
archive: {
show: true,
updateFields: false,
props: { name: 'archive', value: false },
},
archive_before: {
show: true,
updateFields: false,
props: { name: 'archive_before', value: dateInFuture },
},
})
const { data, mockedRemove, mockedSet } = getData(undefined, {
archive: false,
archive_before: dateInFuture,
})
callback(FormHandlerExecution.InitialSettled, reactivity, data)
expect(mockedSet).not.toBeCalled()
expect(mockedRemove).toBeCalledWith('archiveBeforeWarning')
})
it('clears warning when archive_before is cleared (change)', async () => {
const { callback } = useArchiveBeforeWarningHandler()
const dateInFuture = new Date(Date.now() + 1000).toISOString()
const reactivity = getReactivity(undefined, {
archive: {
show: true,
updateFields: false,
props: { name: 'archive', value: true },
},
archive_before: {
show: true,
updateFields: false,
props: { name: 'archive_before', value: null },
},
})
const { data, mockedRemove, mockedSet } = getData(
{
name: 'archive_before',
newValue: null,
oldValue: dateInFuture,
},
{ archive: false, archive_before: null },
)
callback(FormHandlerExecution.FieldChange, reactivity, data)
expect(mockedSet).not.toBeCalled()
expect(mockedRemove).toBeCalledWith('archiveBeforeWarning')
})
it('clears warning when archive_before is in the past (change)', async () => {
const { callback } = useArchiveBeforeWarningHandler()
const dateInPast = new Date(Date.now() - 1000).toISOString()
const reactivity = getReactivity(undefined, {
archive: {
show: true,
updateFields: false,
props: {
name: 'archive',
value: true,
},
},
archive_before: {
show: true,
updateFields: false,
props: {
name: 'archive_before',
value: dateInPast,
},
},
})
const { data, mockedRemove, mockedSet } = getData(
{
name: 'archive_before',
newValue: dateInPast,
oldValue: undefined,
},
{ archive: true, archive_before: dateInPast },
)
callback(FormHandlerExecution.FieldChange, reactivity, data)
expect(mockedSet).not.toBeCalled()
expect(mockedRemove).toBeCalledWith('archiveBeforeWarning')
})
it('sets warning when archive_before is in the future (change)', async () => {
const { callback } = useArchiveBeforeWarningHandler()
const dateInFuture = new Date(Date.now() + 1000).toISOString()
const reactivity = getReactivity(undefined, {
archive: {
show: true,
updateFields: false,
props: {
name: 'archive',
value: true,
},
},
archive_before: {
show: true,
updateFields: false,
props: {
name: 'archive_before',
value: dateInFuture,
},
},
})
const { data, mockedRemove, mockedSet } = getData(
{
name: 'archive_before',
newValue: dateInFuture,
oldValue: undefined,
},
{ archive: true, archive_before: dateInFuture },
)
callback(FormHandlerExecution.FieldChange, reactivity, data)
expect(mockedSet).toHaveBeenCalledWith({
blocking: false,
key: 'archiveBeforeWarning',
meta: {},
type: 'warning',
value:
'You have selected a cut-off time in the future. Be aware that all emails (including future ones) are going to be archived until the selected time is reached.',
visible: true,
})
expect(mockedRemove).not.toBeCalled()
})
it('does not execute when archive_before is not present', async () => {
const { callback } = useArchiveBeforeWarningHandler()
const reactivity = getReactivity(undefined, {
archive: {
show: true,
updateFields: false,
props: { name: 'archive', value: false },
},
})
const { data, mockedRemove, mockedSet } = getData(undefined, {
archive: false,
})
callback(FormHandlerExecution.InitialSettled, reactivity, data)
expect(mockedSet).not.toBeCalled()
expect(mockedRemove).not.toBeCalled()
})
it('does not execute when another field is changed', async () => {
const { callback } = useArchiveBeforeWarningHandler()
const dateInFuture = new Date(Date.now() + 1000).toISOString()
const reactivity = getReactivity(undefined, {
archive: {
show: true,
updateFields: false,
props: {
name: 'archive',
value: true,
},
},
archive_before: {
show: true,
updateFields: false,
props: {
name: 'archive_before',
value: dateInFuture,
},
},
archive_state_id: {
show: true,
updateFields: false,
props: {
name: 'archive_state_id',
value: 4,
},
},
})
const { data, mockedRemove, mockedSet } = getData(
{
name: 'archive_state_id',
newValue: 4,
oldValue: undefined,
},
{ archive: true, archive_before: dateInFuture, archive_state_id: 4 },
)
callback(FormHandlerExecution.FieldChange, reactivity, data)
expect(mockedSet).not.toBeCalled()
expect(mockedRemove).not.toBeCalled()
})
})

View File

@@ -0,0 +1,147 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { createMessage } from '@formkit/core'
import { FormHandlerExecution } from '#shared/components/Form/types.ts'
import type {
ChangedField,
FormHandler,
FormHandlerFunction,
ReactiveFormSchemaDataField,
ReactiveFormSchemData,
} from '#shared/components/Form/types.ts'
import type { FormKitMessage, FormKitNode } from '@formkit/core'
export const useAlertFormHandler = (
nodeName: string,
message: Partial<FormKitMessage> & Pick<FormKitMessage, 'key'>,
initialAddCallback: (
field: ReactiveFormSchemaDataField,
fields: Record<string, ReactiveFormSchemaDataField>,
) => boolean,
changeAddCallback: (
field: ChangedField | undefined,
fields: Record<string, ReactiveFormSchemaDataField>,
) => boolean,
eventHandler: (
node: FormKitNode,
addAlert: () => void,
clearAlert: () => void,
) => void,
eventName: string = 'input',
): FormHandler => {
const addAlert = (formNode?: FormKitNode) => {
formNode?.store.set(
createMessage({
blocking: false,
type: 'warning',
visible: true,
...message,
}),
)
}
const clearAlert = (formNode?: FormKitNode) => {
formNode?.store.remove(message.key)
}
const initializeNodeEvent = (node: FormKitNode, formNode: FormKitNode) => {
node.on(eventName, ({ origin }) => {
eventHandler(
origin,
() => addAlert(formNode),
() => clearAlert(formNode),
)
})
}
const initializeFormNodeEvents = (formNode: FormKitNode) => {
formNode.on('child.deep', ({ payload }) => {
const childNode = payload as FormKitNode
if (childNode.name !== nodeName) return
initializeNodeEvent(childNode, formNode)
childNode.on('destroying', () => {
clearAlert(formNode)
})
})
}
const initializeNodeEvents = (
execution: FormHandlerExecution,
getNodeByName: (id: string) => FormKitNode | undefined,
formNode?: FormKitNode,
) => {
if (execution === FormHandlerExecution.InitialSettled && formNode) {
const node = getNodeByName(nodeName)
if (node) {
initializeNodeEvent(node, formNode)
node.on('destroying', () => {
clearAlert(formNode)
initializeFormNodeEvents(formNode)
})
return
}
initializeFormNodeEvents(formNode)
}
}
const executeHandler = (
execution: FormHandlerExecution,
schemaData: ReactiveFormSchemData,
changedField?: ChangedField,
formNode?: FormKitNode,
) => {
if (
schemaData.fields[nodeName] === undefined ||
schemaData.fields[nodeName] === null ||
(execution === FormHandlerExecution.FieldChange &&
(!changedField || changedField.name !== nodeName)) ||
(typeof formNode?.find === 'function' && !formNode?.find(nodeName))
) {
return false
}
return true
}
const alertFormHandler: FormHandlerFunction = (
execution,
reactivity,
data,
) => {
const { changedField, formNode, getNodeByName } = data
const { schemaData } = reactivity
initializeNodeEvents(execution, getNodeByName, formNode)
if (!executeHandler(execution, schemaData, changedField, formNode)) return
if (
(execution === FormHandlerExecution.InitialSettled &&
initialAddCallback(schemaData.fields[nodeName], schemaData.fields)) ||
(execution === FormHandlerExecution.FieldChange &&
changeAddCallback(changedField, schemaData.fields))
) {
addAlert(formNode)
return
}
clearAlert(formNode)
}
return {
execution: [
FormHandlerExecution.InitialSettled,
FormHandlerExecution.FieldChange,
],
callback: alertFormHandler,
}
}

View File

@@ -0,0 +1,37 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { isFuture, parseISO } from 'date-fns'
import { i18n } from '#shared/i18n.ts'
import { useAlertFormHandler } from './useAlertFormHandler.ts'
export const useArchiveBeforeWarningHandler = () =>
useAlertFormHandler(
'archive_before',
{
key: 'archiveBeforeWarning',
value: i18n.t(
'You have selected a cut-off time in the future. Be aware that all emails (including future ones) are going to be archived until the selected time is reached.',
),
},
(field, fields) =>
Boolean(
fields.archive.props.value &&
field.props.value &&
isFuture(parseISO(field.props.value as string)),
),
(changedField, fields) =>
Boolean(
fields.archive.props.value &&
changedField?.name === 'archive_before' &&
changedField.newValue &&
isFuture(parseISO(changedField.newValue as string)),
),
(node, addAlert, clearAlert) => {
const { value } = node
if (value && isFuture(parseISO(value as string))) addAlert()
else clearAlert()
},
)

View File

@@ -1,134 +1,26 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { createMessage } from '@formkit/core'
import { FormHandlerExecution } from '#shared/components/Form/types.ts'
import type {
ChangedField,
FormHandler,
FormHandlerFunction,
ReactiveFormSchemData,
} from '#shared/components/Form/types.ts'
import { i18n } from '#shared/i18n.ts'
import type { FormKitNode } from '@formkit/core'
import { useAlertFormHandler } from './useAlertFormHandler.ts'
export const useSSLVerificationWarningHandler = (): FormHandler => {
const addWarning = (formNode?: FormKitNode) => {
formNode?.store.set(
createMessage({
blocking: false,
key: 'sslVerificationWarning',
type: 'warning',
value: i18n.t(
'Turning off SSL verification is a security risk and should be used only temporary. Use this option at your own risk!',
),
visible: true,
}),
)
}
export const useSSLVerificationWarningHandler = () =>
useAlertFormHandler(
'sslVerify',
{
key: 'sslVerificationWarning',
value: i18n.t(
'Turning off SSL verification is a security risk and should be used only temporary. Use this option at your own risk!',
),
},
(field) => !field.props.disabled && field.props.value === false,
(changedField, fields) =>
!fields.sslVerify.props.disabled && changedField?.newValue === false,
(node, addAlert, clearAlert) => {
const { props, value } = node
const clearWarning = (formNode?: FormKitNode) => {
formNode?.store.remove('sslVerificationWarning')
}
const initializeSSLVerifyDisabledNodeEvent = (
sslFieldNode: FormKitNode,
formNode: FormKitNode,
) => {
sslFieldNode.on('prop:disabled', ({ origin }) => {
const { props, value } = origin
if (props.disabled) clearWarning(formNode)
else if (value === false) addWarning(formNode)
})
}
const initializeFormNodeEvents = (formNode: FormKitNode) => {
formNode.on('child.deep', ({ payload }) => {
const childNode = payload as FormKitNode
if (childNode.name !== 'sslVerify') return
initializeSSLVerifyDisabledNodeEvent(childNode, formNode)
childNode.on('destroying', () => {
clearWarning(formNode)
})
})
}
const initializeSSLVerifyNodeEvents = (
execution: FormHandlerExecution,
getNodeByName: (id: string) => FormKitNode | undefined,
formNode?: FormKitNode,
) => {
if (execution === FormHandlerExecution.InitialSettled && formNode) {
const sslFieldNode = getNodeByName('sslVerify')
if (sslFieldNode) {
initializeSSLVerifyDisabledNodeEvent(sslFieldNode, formNode)
sslFieldNode.on('destroying', () => {
clearWarning(formNode)
initializeFormNodeEvents(formNode)
})
} else {
initializeFormNodeEvents(formNode)
}
}
}
const executeHandler = (
execution: FormHandlerExecution,
schemaData: ReactiveFormSchemData,
changedField?: ChangedField,
formNode?: FormKitNode,
) => {
if (
schemaData.fields.sslVerify === undefined ||
schemaData.fields.sslVerify === null ||
(execution === FormHandlerExecution.FieldChange &&
(!changedField || changedField.name !== 'sslVerify')) ||
(typeof formNode?.find === 'function' && !formNode?.find('sslVerify'))
) {
return false
}
return true
}
const handleSSLVerificationWarning: FormHandlerFunction = (
execution,
reactivity,
data,
) => {
const { changedField, formNode, getNodeByName } = data
const { schemaData } = reactivity
initializeSSLVerifyNodeEvents(execution, getNodeByName, formNode)
if (!executeHandler(execution, schemaData, changedField, formNode)) return
if (
!schemaData.fields.sslVerify.props.disabled &&
((execution === FormHandlerExecution.InitialSettled &&
schemaData.fields.sslVerify.props.value === false) ||
(execution === FormHandlerExecution.FieldChange &&
changedField?.newValue === false))
) {
addWarning(formNode)
return
}
clearWarning(formNode)
}
return {
execution: [
FormHandlerExecution.InitialSettled,
FormHandlerExecution.FieldChange,
],
callback: handleSSLVerificationWarning,
}
}
if (props.disabled) clearAlert()
else if (value === false) addAlert()
},
'prop:disabled',
)

View File

@@ -19,6 +19,7 @@ import type {
EmailOutboundData,
EmailInboundMessagesData,
} from '#desktop/entities/channel-email/types/email-inbound-outbound.ts'
import { useArchiveBeforeWarningHandler } from '#desktop/form/composables/useArchiveBeforeWarningHandler.ts'
import { useSSLVerificationWarningHandler } from '#desktop/form/composables/useSSLVerificationWarningHandler.ts'
import GuidedSetupActionFooter from '../../components/GuidedSetupActionFooter.vue'
@@ -222,6 +223,7 @@ const emailConfigurationCheck = computed(() => {
ref="formEmailInboundMessages"
data-test-id="channel-email-inbound-messages"
form-class="mb-2.5"
:handlers="[useArchiveBeforeWarningHandler()]"
:form-updater-id="
EnumFormUpdaterId.FormUpdaterUpdaterGuidedSetupEmailArchive
"

View File

@@ -54,13 +54,13 @@ const notificationCount = computed(() => {
</CommonLink>
<CommonLink
link="/account"
class="group flex-1"
class="group flex flex-1 justify-center"
exact-active-class="user-active"
>
<CommonUserAvatar
v-if="user"
:entity="user"
class="group-[.user-active]:ring-blue inline-flex group-[.user-active]:ring-2"
class="group-[.user-active]:ring-blue group-[.user-active]:rounded-full group-[.user-active]:ring-2"
size="small"
personal
/>

View File

@@ -1,5 +1,8 @@
{
"private": true,
"pnpm": {
"neverBuiltDependencies": []
},
"imports": {
"#tests/*": "./tests/*",
"#shared/*": "./shared/*",

View File

@@ -184,19 +184,18 @@ export type FormSchemaNode =
| FormSchemaFieldsForObjectAttributeScreen
| string
export interface ReactiveFormSchemData {
fields: Record<
string,
{
show: boolean
staticCondition: boolean
updateFields: boolean
props: Except<
SetOptional<FormSchemaField, 'type'>,
'show' | 'props' | 'updateFields' | 'relation'
>
}
export interface ReactiveFormSchemaDataField {
show: boolean
staticCondition: boolean
updateFields: boolean
props: Except<
SetOptional<FormSchemaField, 'type'>,
'show' | 'props' | 'updateFields' | 'relation'
>
}
export interface ReactiveFormSchemData {
fields: Record<string, ReactiveFormSchemaDataField>
flags: Record<string, boolean>
[index: string]: unknown

View File

@@ -21,15 +21,13 @@ module Gql::Mutations
end
def resolve(username:)
verify = Service::User::PasswordReset::Send.new(username: username)
begin
verify.execute
rescue Service::User::PasswordReset::Send::EmailError => e
return error_response({ message: e.message })
end
Service::User::PasswordReset::Send
.new(username: username)
.execute
{ success: true }
rescue Exceptions::UnprocessableEntity => e
error_response({ message: e.message })
end
end
end

View File

@@ -19,14 +19,15 @@ module Gql::Mutations
end
def resolve(input:)
signup = Service::User::Signup.new(user_data: input.to_h)
begin
signup.execute
rescue PasswordPolicy::Error => e
return error_response({ message: e.message, field: 'password' })
end
Service::User::Signup
.new(user_data: input.to_h)
.execute
{ success: true }
rescue PasswordPolicy::Error => e
error_response({ message: e.message, field: 'password' })
rescue Exceptions::UnprocessableEntity => e
error_response({ message: e.message })
end
end
end

View File

@@ -45,6 +45,11 @@ class TicketArticleCommunicateEmailJob < ApplicationJob
end
channel = email_address.channel
if !channel.active
log_error(record, "Channel defined for email address id '#{email_address.id}' is not active!", channel)
return
end
notification = false
sender = Ticket::Article::Sender.lookup(id: record.sender_id)
if sender['name'] == 'System'

View File

@@ -68,17 +68,18 @@ class TriggerWebhookJob < ApplicationJob
webhook.endpoint,
payload,
{
json: true,
jsonParseDisable: true,
open_timeout: 4,
read_timeout: 30,
total_timeout: 60,
headers: headers,
signature_token: webhook.signature_token,
verify_ssl: webhook.ssl_verify,
user: webhook.basic_auth_username,
password: webhook.basic_auth_password,
log: {
json: true,
jsonParseDisable: true,
open_timeout: 4,
read_timeout: 30,
total_timeout: 60,
headers: headers,
signature_token: webhook.signature_token,
verify_ssl: webhook.ssl_verify,
user: webhook.basic_auth_username,
password: webhook.basic_auth_password,
do_not_follow_redirects: true,
log: {
facility: 'webhook',
},
},

View File

@@ -193,6 +193,7 @@ stream all accounts
# start threads for each channel
@@channel_stream[channel_id][:thread] = Thread.new do
Thread.current.name = "stream channel for '#{channel.id}' (#{channel.area})..."
logger.info "Started stream channel for '#{channel.id}' (#{channel.area})..."
channel.status_in = 'ok'

View File

@@ -247,19 +247,19 @@ returns
return MessageResult.new(success: false, after_action: [:result_error, notice])
end
return MessageResult.new(sucess: false) if message_meta.nil?
return MessageResult.new(success: false) if message_meta.nil?
message_validator = MessageValidator.new(self.class.extract_rfc822_headers(message_meta), message_meta.attr['RFC822.SIZE'])
if message_validator.fresh_verify_message?
Rails.logger.info " - ignore message #{count}/#{count_all} - because message has a verify message"
return MessageResult.new(sucess: false)
return MessageResult.new(success: false)
end
# ignore deleted messages
if deleted?(message_meta, count, count_all)
return MessageResult.new(sucess: false)
return MessageResult.new(success: false)
end
# ignore already imported
@@ -269,10 +269,10 @@ returns
end
Rails.logger.info " - ignore message #{count}/#{count_all} - because message message id already imported"
return MessageResult.new(sucess: false)
return MessageResult.new(success: false)
end
# delete email from server after article was created
# fetch message body
msg = begin
Timeout.timeout(FETCH_MSG_TIMEOUT) do
key = fetch_message_body_key(@options)
@@ -284,7 +284,7 @@ returns
end
if !msg
return MessageResult.new(sucess: false)
return MessageResult.new(success: false)
end
# do not process too big messages, instead download & send postmaster reply
@@ -304,6 +304,7 @@ returns
process(@channel, msg, false)
end
# delete email from server after article was created
begin
Timeout.timeout(FETCH_MSG_TIMEOUT) do
if @keep_on_server

View File

@@ -106,7 +106,7 @@ class Channel::Driver::Pop3 < Channel::Driver::BaseEmailInbound
if message_validator.fresh_verify_message?
Rails.logger.info " - ignore message #{count}/#{count_all} - because message has a verify message"
return MessageResult.new(sucess: false)
return MessageResult.new(success: false)
end
# do not process too large messages, instead download and send postmaster reply

View File

@@ -13,9 +13,11 @@ class CoreWorkflow::Result::SetFixedTo < CoreWorkflow::Result::BaseOption
end
def config_value
result = Array(@perform_config['set_fixed_to'])
result |= saved_value
result
@config_value ||= begin
result = Array(@perform_config['set_fixed_to'])
result |= saved_value
result
end
end
def restriction_set?

View File

@@ -12,10 +12,10 @@ class Group
end
def authorized_asset?
return true if UserInfo.assets.blank? || UserInfo.assets.agent? || Setting.get('customer_ticket_create_group_ids').blank?
return true if UserInfo.current_user.blank?
allowed_group_ids = Auth::RequestCache.fetch_value("Group/Assets/authorized_asset/groups/#{UserInfo.current_user_id}") do
Array.wrap(Setting.get('customer_ticket_create_group_ids')).map(&:to_i) | TicketPolicy::ReadScope.new(UserInfo.current_user).resolve.distinct(:group_id).pluck(:group_id)
GroupPolicy::Scope.new(UserInfo.current_user, Group).resolve.pluck(:id)
end
allowed_group_ids.include?(id)

View File

@@ -151,6 +151,11 @@ reload config settings
end
@@raw.each do |key, value|
if key == 'notification_sender'
@@current[key] = sanitize_notification_sender value
next
end
@@current[key] = interpolate_value value
end
end
@@ -171,6 +176,24 @@ reload config settings
end
private_class_method :interpolate_value
def self.sanitize_notification_sender(input)
return input if !input.is_a? String
input.gsub(%r{\#\{config\.(.+?)\}}) do
placeholder = @@raw[$1].to_s
case $1
when 'fqdn'
placeholder = placeholder.sub(%r{:\d+$}, '') # strip port from fqdn
when 'product_name'
placeholder = "\"#{placeholder.gsub(%r{"}, "''")}\"" # quote and replace double quotes
end
placeholder
end
end
private_class_method :sanitize_notification_sender
# set initial value in state_initial
def set_initial
self.state_initial = state_current

View File

@@ -10,9 +10,11 @@ class SystemReport::Plugin::Environment < SystemReport::Plugin
'ZAMMAD_RAILS_PORT' => ENV['ZAMMAD_RAILS_PORT'].present?,
'ZAMMAD_WEBSOCKET_PORT' => ENV['ZAMMAD_WEBSOCKET_PORT'].present?,
'WEB_CONCURRENCY' => ENV['WEB_CONCURRENCY'],
'ZAMMAD_SESSION_JOBS_CONCURRENT' => ENV['ZAMMAD_SESSION_JOBS_CONCURRENT'],
'ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS' => ENV['ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS'],
'ZAMMAD_PROCESS_SCHEDULED_JOBS_WORKERS' => ENV['ZAMMAD_PROCESS_SCHEDULED_JOBS_WORKERS'],
'ZAMMAD_PROCESS_DELAYED_JOBS_WORKERS' => ENV['ZAMMAD_PROCESS_DELAYED_JOBS_WORKERS'],
# deprecated settings
'ZAMMAD_SESSION_JOBS_CONCURRENT' => ENV['ZAMMAD_SESSION_JOBS_CONCURRENT'],
}
end
end

View File

@@ -12,7 +12,17 @@ class SystemReport::Plugin::Hardware < SystemReport::Plugin
end
def total_memory
open3_data&.dig('children')&.find { |entry| entry['description'].downcase == 'motherboard' }&.dig('children')&.find { |entry| entry['description'].downcase == 'system memory' }&.dig('size')
memory = open3_data&.dig('children')&.find { |entry| entry['description'].downcase == 'motherboard' }&.dig('children')&.find { |entry| entry['description'].downcase == 'system memory' }&.dig('size')
return memory if memory.present?
# Fallback to /proc/meminfo if lshw is missing or fails
begin
mem_kb = File.read('/proc/meminfo')[%r{MemTotal:\s+(\d+) kB}, 1].to_i
mem_kb * 1024
rescue
nil
end
end
def df_zammad_root

View File

@@ -514,11 +514,7 @@ perform active triggers on ticket
next if ticket_count.zero?
next if tickets.take.id != ticket.id
if recursive == false && local_options[:loop_count] > 1
message = "Do not execute recursive triggers per default until Zammad 3.0. With Zammad 3.0 and higher the following trigger is executed '#{trigger.name}' on Ticket:#{ticket.id}. Please review your current triggers and change them if needed."
logger.info { message }
return [true, message]
end
return [true, message] if recursive == false && local_options[:loop_count] > 1
if article && send_notification == false && trigger.perform['notification.email'] && trigger.perform['notification.email']['recipient']
recipient = trigger.perform['notification.email']['recipient']

View File

@@ -140,15 +140,20 @@ class Transaction::Notification
end
end
def recipient_myself?(user)
return false if @params[:interface_handle] != 'application_server'
return true if article&.updated_by_id == user.id
return true if !article && @item[:user_id] == user.id
false
end
def send_to_single_recipient(recipient_settings)
user = recipient_settings[:user]
channels = recipient_settings[:channels]
# ignore user who changed it by him self via web
if @params[:interface_handle] == 'application_server'
return if article&.updated_by_id == user.id
return if !article && @item[:user_id] == user.id
end
return if recipient_myself?(user)
# ignore inactive users
return if !user.active?

View File

@@ -367,7 +367,7 @@ returns
# try second lookup with email
user ||= User.find_by(email: username.downcase.strip, active: true)
return if !user || !user.email
return if !user || user.email.blank?
# Discard any possible previous tokens for safety reasons.
Token.where(action: 'PasswordReset', user_id: user.id).destroy_all

View File

@@ -1,5 +1,6 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class Controllers::GroupsControllerPolicy < Controllers::ApplicationControllerPolicy
permit! %i[index show], to: ['ticket.agent', 'admin.group', 'ticket.customer']
default_permit!('admin.group')
end

View File

@@ -1,5 +1,6 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class Controllers::RolesControllerPolicy < Controllers::ApplicationControllerPolicy
permit! %i[index show], to: ['ticket.agent', 'admin.role', 'ticket.customer']
default_permit!('admin.role')
end

View File

@@ -27,6 +27,6 @@ class Controllers::TicketSharedDraftZoomControllerPolicy < Controllers::Applicat
ticket_id = record.params[:ticket_id]
ticket = Ticket.find ticket_id
TicketPolicy.new(user, ticket).update?
TicketPolicy.new(user, ticket).agent_update_access?
end
end

View File

@@ -0,0 +1,17 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class GroupPolicy < ApplicationPolicy
class Scope < ApplicationPolicy::Scope
def resolve
return scope.all if Setting.get('customer_ticket_create_group_ids').blank?
return scope.all if user.permissions?(['ticket.agent', 'admin.group'])
allowed_group_ids = Auth::RequestCache.fetch_value("GroupPolicy/Scope/allowed_group_ids/#{user.id}") do
Array.wrap(Setting.get('customer_ticket_create_group_ids')).map(&:to_i) | TicketPolicy::ReadScope.new(user).resolve.distinct(:group_id).pluck(:group_id)
end
scope.where(id: allowed_group_ids)
end
end
end

View File

@@ -16,6 +16,6 @@ class Ticket::SharedDraftZoomPolicy < ApplicationPolicy
private
def access?(_method)
TicketPolicy.new(user, record.ticket).update?
TicketPolicy.new(user, record.ticket).agent_update_access?
end
end

Some files were not shown because too many files have changed in this diff Show More