Compare commits

...

181 Commits

Author SHA1 Message Date
Florian Liebe
7195a4a3e3 Release Zammad 7.1.2. 🚀 2026-08-04 08:16:44 +02:00
Rene Reimann
13217eb6b3 Follow-up c8d56659e0755994bf1c9d1f26bc3e902c5fdba8 - Maintenance: Improve listing of tags via API. 2026-08-03 10:36:30 +02:00
Florian Liebe
fe8fb9bd6c Maintenance: Improve article rendering. 2026-08-03 10:36:30 +02:00
Rene Reimann
3b755736fd Maintenance: Improve attachment upload policy 2026-08-03 10:36:30 +02:00
Rene Reimann
d1a5eabdd1 Maintenance: Improve listing of tags via API. 2026-08-03 10:36:30 +02:00
Florian Liebe
9a07d0b821 Maintenance: Improve certificate handling in S/MIME integration. 2026-08-03 10:36:30 +02:00
Florian Liebe
f7a97ea209 Maintenance: Improve handling of knowledge base video markers.
Approved-by: Mantas Masalskis <mm@zammad.com>
2026-08-03 10:36:30 +02:00
Florian Liebe
0dba387df8 Maintenance: Improve automatic account link on initial logon feature for SSO.
Co-authored-by: Rene Reimann <rr@zammad.com>
2026-08-03 10:36:30 +02:00
Florian Liebe
34bdf46273 Maintenance: Improve external data source context rendering. 2026-08-03 10:36:30 +02:00
Florian Liebe
d51254f1c6 Maintenance: Improve 2FA initiation endpoints. 2026-08-03 10:36:30 +02:00
Florian Liebe
e3b0c8df91 Maintenance: Improve ERB sanitizer. 2026-08-03 10:36:30 +02:00
Florian Liebe
849191c32a Maintenance: Improve ticket summarize controller. 2026-08-03 10:36:30 +02:00
Florian Liebe
2be46473ee Maintenance: Improve HTTP log cleanup handling. 2026-08-03 10:36:30 +02:00
Rene Reimann
36fb4d9781 Maintenance: Improve attachment clone endpoint. 2026-08-03 10:36:30 +02:00
Florian Liebe
856af85c12 Maintenance: Improve unsafe API path detection in HTML sanitizer's wipe functionality. 2026-08-03 10:36:30 +02:00
Florian Liebe
74b4fd4db6 Maintenance: Improve displaying of option labels in the UI. 2026-08-03 10:36:30 +02:00
Mantas Masalskis
f3e4da8362 Maintenance: Improve referenced attachments handling 2026-08-03 10:36:30 +02:00
Florian Liebe
a5878f37e4 Maintenance: Improve remote URL schema checking in HTML sanitizer. 2026-08-03 10:36:30 +02:00
Florian Liebe
dd22716ced Maintenance: Improve verification of detached PGP signatures. 2026-08-03 10:36:30 +02:00
Florian Liebe
fab2e2e9da Maintenance: Improve masking of sensitive fields in dry run mode for LDAP + Exchange. 2026-08-03 10:36:30 +02:00
Florian Liebe
beb4c67604 Maintenance: Update version. 2026-08-03 10:36:30 +02:00
Florian Liebe
3af7e5e316 Maintenance: Update graphql to 2.6.7. 2026-08-03 08:31:06 +02:00
Rolf Schmidt
8f28689a42 Fixes #6272 - Zammad claims PGP signature is invalid when it's valid.
Approved-by: Rene Reimann rr@zammad.com
2026-07-30 19:30:29 +02:00
Dusan Vuckovic
a15cd172a7 Fixes #6273 - A failed update of a frontend setting results in an unsynced state.
Approved-by: Rolf Schmidt <rolf.schmidt@zammad.com>

Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
2026-07-30 13:21:58 +01:00
Rolf Schmidt
ea8330b3b7 Maintenance: Update activestorage gem.
Approved-by: Florian Liebe <fl@zammad.com>
2026-07-30 11:45:51 +02:00
Florian Liebe
be4516bc93 Maintenance: Use openSUSE dependencies for building Zammad package. 2026-07-29 09:44:54 +02:00
Weblate
dadb5f48db Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
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/da/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/de/
Translation: Zammad/Zammad (stable version)
2026-07-29 05:32:25 +00:00
Weblate
af9456d45d Maintenance: Update translated files and catalog 2026-07-28 07:49:02 +01:00
Weblate
82c1433c9b Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Andi Chandler <andi@gowling.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Igor Costa <igor_sfx@yahoo.com.br>
Co-authored-by: Marcel Bialas <mtzl.29@proton.me>
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
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/da/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/de/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/en_GB/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/hu/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/pt_BR/
Translation: Zammad/Zammad (stable version)
2026-07-28 06:45:20 +00:00
Rolf Schmidt
129ad589c5 Follow up f6e6abc66e - Fixes #6232 - do not resolve non search index models from selector condition names.
Approved-by: Rene Reimann rr@zammad.com
2026-07-27 11:54:45 +02:00
Martin Gruner
259923f25e Follow-up a4de4cb9b0 - Reindexing after restore must be a manual action
It turned out that ES may not be present / fully initialized at the
time the restore runs, and we cannot declare it as a dependency in the docker
stack because it can be disabled.

Therefore, the elasticsearch reindexing after restore will be documented
for now as a manual step.


(cherry picked from commit fcb1c2ac34)

Co-authored-by: Martin Gruner <mg@zammad.com>
2026-07-22 09:50:38 +02:00
Rolf Schmidt
54c6c4bb7f Fixes #6254 - GET /api/v1/ticket_articles/{id} no longer lists inline attachments.
Approved-by: Mantas Masalskis <mm@zammad.com>
2026-07-20 17:46:34 +02:00
Weblate
df4853d431 Maintenance: Update translated files and catalog 2026-07-20 08:00:07 +01:00
Weblate
45d6580727 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Anael Mobilia <contrib@anael.eu>
Co-authored-by: Andi Chandler <andi@gowling.com>
Co-authored-by: Anntoo <jdoomm@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Igor Costa <igor_sfx@yahoo.com.br>
Co-authored-by: Karoly Nagy <cpt.charlesg@gmail.com>
Co-authored-by: MBekspert <michal.bosak@ekspert.biz>
Co-authored-by: Murat Duman <mduman@metu.edu.tr>
Co-authored-by: Ralf Schmid <rsc@zammad.com>
Co-authored-by: Random <random_r@users.noreply.translations.zammad.org>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
Co-authored-by: WKobes <github@wouterkobes.nl>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: aiworker-dev <aiworker8889@hotmail.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/el/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/en_GB/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/fr/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/hu/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/it/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/nl/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/pl/
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/tr/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/uk/
Translation: Zammad/Zammad (stable version)
2026-07-20 06:52:49 +00:00
Martin Gruner
34dba5b33e Maintenance: Apply gem security updates for loofah and rails-html-sanitizer 2026-07-20 07:59:54 +02:00
Rolf Schmidt
8d2427e4f2 Maintenance: Reset the user password on save. 2026-07-17 17:19:44 +02:00
Florian Liebe
7808d16baf Fixes #6251 - Signatures lose text styling (e.g. font color) from pasted content. 2026-07-17 08:28:07 +02:00
Florian Liebe
d50dab4694 Fixes #6239 - Knowledge Base custom domain (hostname only) reveals internal /help path into Atom feed URLs and generates malformed alternate and self links. 2026-07-16 08:20:15 +02:00
Mantas Masalskis
018cbb1bf0 Follow up - 223d97c6 - Fixes #6115 - Header Based Actions not working in 7.0. 2026-07-16 08:31:54 +03:00
Dominik Klein
6be04bf6db Fixes #6219 - Ticket-Tagger Agent creates tags on its own. 2026-07-14 07:41:45 +02:00
Mantas Masalskis
043e0a5d88 Fixes #6226 - Bulk ticket import suffers from extreme negative scaling with high Delayed::Job count
Co-authored-by: Mantas Masalskis <mantas@idev.lt>
2026-07-10 16:32:13 +03:00
Rolf Schmidt
f701041bb3 Fixes #3880 - Changing ticket status from "new" to any other status always results in uncommited status "closed".
Approved-by: Florian Liebe fl@zammad.com
2026-07-10 09:17:40 +02:00
Marcel Bialas
67dffc883d Fixes #6227 - Inline images dropped on save after clicking them to resize
Co-authored-by: Florian Liebe <fl@zammad.com>
2026-07-09 15:55:27 +02:00
Florian Liebe
1ac064d58a Maintenance: Update websocket-driver to 0.8.2.
Approved-by: Florian Liebe <fl@zammad.com>
2026-07-09 08:58:12 +02:00
Rolf Schmidt
82b813106f Fixes #6232 - Selector "contains" on relation fields (e.g. customer.email) no longer matches values containing "@".
Approved-by: Mantas Masalskis mm@zammad.com
2026-07-08 18:38:28 +02:00
Marcel Bialas
24c59d60cd Fixes #6214 - Include FQDN of mail server in healthcheck error messages.
Co-authored-by: Martin Edenhofer <me@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Marcel Bialas <mb@zammad.com>
2026-07-08 16:15:26 +02:00
Dusan Vuckovic
1674d5ce65 Fixes #6234 - Ticket summary: Bad escaping of quote characters 2026-07-08 12:59:29 +01:00
Dusan Vuckovic
ba1c0e39ba Fixes #6233 - "Sending an email without a valid recipient is not possible" error message only visible to admins but not agents. 2026-07-08 11:05:17 +01:00
Mantas Masalskis
9e4da10164 Maintenance: Update dependency mail to v2.9.1
Co-authored-by: Mantas <mantas@idev.lt>
2026-07-08 13:00:53 +03:00
Dominik Klein
82a63e5e1f Fixes #6222 - Reopened ticket by a trigger (on new customer article) loses owner even if assign follow up is on. 2026-07-07 19:42:40 +02:00
Marcel Bialas
487f3c3087 Fixes #6216 - Resized images cannot be enlarged again after saving
Approved-by: Florian Liebe <fl@zammad.com>
2026-07-07 15:30:56 +02:00
Rolf Schmidt
a82858faf1 Maintenance: Add claude skills and local settings to .gitignore 2026-07-06 10:06:24 +02:00
Florian Liebe
bf8c7c4ea2 Fixes #6169 - Translation - Article (HTML rendering issues) - missing translation. 2026-07-02 11:27:15 +02:00
Dominik Klein
822af567bb Maintenance: Desktop view - Fix Apollo cache redirect for ticket, user and... 2026-06-30 22:32:16 +02:00
Martin Gruner
e27a61311c Maintenance: Improve docker restore to clean up cache and drop old ES indices
Fixes https://github.com/zammad/zammad-docker-compose/issues/564

Approved-by: Dusan Vuckovic <dv@zammad.com>


(cherry picked from commit a4de4cb9b0)

4d2dc622 Maintenance: Improve docker restore to clean up cache and drop old ES indices
218ac164 Debug
175c2690 Switch back branch and move cleanup logic
350d0458 Restore commented part for debugging

Co-authored-by: Martin Gruner <mg@zammad.com>
2026-06-30 13:45:10 +02:00
Dusan Vuckovic
9c0b19beb5 Fixes #6217 - Email processing fails when S/MIME signature verification encounters an error. 2026-06-30 08:44:40 +01:00
Florian Liebe
2c466b6036 Maintenance: Update msgpack to 1.8.3. 2026-06-30 07:38:35 +02:00
Weblate
6c1b14f348 Maintenance: Update translated files and catalog 2026-06-29 10:21:53 +01:00
Weblate
429b7caf36 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: 0xlabuk <contact@0xlab.uk>
Co-authored-by: Andi Chandler <andi@gowling.com>
Co-authored-by: Anntoo <jdoomm@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Marcel Herrguth <github@thehomeofanime.de>
Co-authored-by: Ralf Schmid <rsc@zammad.com>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: albanobattistella <albanobattistella@gmail.com>
Co-authored-by: behrad.b <behroora@yahoo.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/en_GB/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/fa/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/hu/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/it/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/pt_BR/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/uk/
Translation: Zammad/Zammad (stable version)
2026-06-29 09:15:05 +00:00
Florian Liebe
8450d40430 Maintenance: Update yard to 0.9.44. 2026-06-29 07:40:55 +02:00
Mantas Masalskis
efcfe9f567 Maintenance: Update crass to 1.0.7 (GHSA-6jxj-px6v-747w, GHSA-6wmf-3r64-vcwv,...
Co-authored-by: Mantas Masalskis <mantas@idev.lt>
2026-06-28 19:11:53 +03:00
Dusan Vuckovic
f1f6249cae Follow-up 1a33abad0d - Fixes #6064 - Add new agent type for ticket tagging. 2026-06-26 10:46:32 +01:00
Martin Gruner
8f573d3d22 Enhancement: Allow to manually create single backups in docker
Approved-by: Dusan Vuckovic <dv@zammad.com>


(cherry picked from commit e656091b10)

bf06f1f3 Enhancement: Allow to manually create single backups in docker
9c1dc228 Move logic to a more appropriate place

Co-authored-by: Martin Gruner <mg@zammad.com>
2026-06-26 07:49:03 +02:00
Dominik Klein
0c29755e60 Fixes #6215 - AI-Agent text extractor can fail when numbers are using in select/treeselect options. 2026-06-25 14:48:16 +02:00
Florian Liebe
51afae3a48 Maintenance: Update changelog generation. 2026-06-25 12:41:30 +02:00
Florian Liebe
b7790398b6 Maintenance: Update release notes link.
Approved-by: Martin Gruner <mg@zammad.com>
2026-06-25 08:57:56 +02:00
Florian Liebe
cdd01e9160 Release Zammad 7.1.1. 🚀 2026-06-25 07:17:20 +02:00
Rene Reimann
f78ef2434f Maintenance: Improve validation of client identifiers in session handling. 2026-06-23 10:01:16 +02:00
Florian Liebe
2c3a6a06f9 Maintenance: Update concurrent-ruby to 1.3.7. 2026-06-23 08:10:43 +02:00
Dominik Klein
a362bbace0 Maintenance: Fix problem with not serializable delegated user object via user context. 2026-06-22 22:43:34 +02:00
Dominik Klein
0e54acf658 Fixes #6207 - Switching to Beta-UI while editing an internal note changes that note to public 2026-06-22 20:50:19 +02:00
Rolf Schmidt
1cf7f2d674 Maintenance: Update faraday to 2.14.3. 2026-06-22 18:39:03 +02:00
Florian Liebe
a2749f2db7 Maintenance: Update nokogiri to 1.19.4.
Approved-by: Florian Liebe <fl@zammad.com>
2026-06-22 09:16:51 +02:00
Rolf Schmidt
94d8a58938 Fixes #4994 - empty page if switch to a disabled account. 2026-06-19 16:59:47 +02:00
Rolf Schmidt
954fda2e3c Maintenance: Make bootstrap.sh working again with new rails version. 2026-06-19 15:05:34 +02:00
Rolf Schmidt
e79f8b44b9 Fixes #6119 - Fix LDAP dry-run preview showing 0 users and wrong role counts.
Approved-by: Florian Liebe fl@zammad.com

Co-authored-by: Florian Liebe fl@zammad.com
2026-06-19 14:36:19 +02:00
Mantas Masalskis
0e34f3e410 Fixes #6199 - password_reset_verify is wrongly throttled by the password_reset rate limit 2026-06-19 13:00:21 +03:00
Florian Liebe
292515f7e1 Maintenance: Make sure impersonation feature is using the correct permission call. 2026-06-19 09:29:06 +02:00
Rene Reimann
497fed7cda Fixes #5128 - include forward slash in CTI caller ID extraction 2026-06-18 15:36:18 +02:00
Florian Liebe
cbb2a4fbc5 Maintenance: Remove stale files while updating Zammad for RPM based distributions.
Approved-by: Martin Gruner <mg@zammad.com>
2026-06-18 13:52:02 +02:00
Dominik Klein
f26deed718 Fixes #6204 - Button overflow in ticket checklist confirmation dialog. 2026-06-17 23:30:15 +02:00
Florian Liebe
234ce93925 Maintenance: Improve output of invalid attributes during perform changes execution. 2026-06-17 14:35:01 +02:00
Weblate
69568e3432 Maintenance: Update translated files and catalog 2026-06-17 10:35:08 +01:00
Weblate
c694208b68 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
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/da/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/hu/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sr_Cyrl/
Translation: Zammad/Zammad (pre-release version)
Translation: Zammad/Zammad (stable version)
2026-06-17 09:31:23 +00:00
Florian Liebe
1c321bf58e Maintenance: Update for openCode. 2026-06-17 11:00:10 +02:00
Florian Liebe
b098c818e0 Maintenance: Update buildpack ruby. 2026-06-17 08:03:20 +02:00
Florian Liebe
ecbb861ce3 Release Zammad 7.1.0. 🚀 2026-06-17 07:41:04 +02:00
Benjamin Scharf
cc23d5d8b1 Fixes #6184 - Public links do not get translated 2026-06-16 22:26:15 +02:00
Benjamin Scharf
98513be2ea Fixes #6181 - Misleading tooltip text for admin settings button
Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
2026-06-16 21:16:43 +01:00
Dusan Vuckovic
a7867c1c50 Follow-up fc3c8fbfa2 - Fixes #6123 - Ticket history notification action is untranslated in the UI.
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
2026-06-16 14:38:25 +01:00
Dusan Vuckovic
fc3c8fbfa2 Fixes #6123 - Ticket history "notification" action is untranslated in the UI.
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
2026-06-16 13:00:32 +01:00
Weblate
3f0d91eb52 Maintenance: Update translated files and catalog 2026-06-16 09:16:45 +01:00
Weblate
9d13c3cb5b Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Anntoo <jdoomm@gmail.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Gerrit <github@gmail.arghs.de>
Co-authored-by: Mantikor <nhyuga.r@gmail.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Ralf Schmid <rsc@zammad.com>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: albanobattistella <albanobattistella@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/it/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/sr_Cyrl/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/uk/
Translation: Zammad/Zammad (pre-release version)
2026-06-16 08:13:46 +00:00
Florian Liebe
83039994ec Follow up ff20694223 - Maintenance: Improve user context with token handling. 2026-06-16 08:03:50 +02:00
Rolf Schmidt
098384851e Maintenance: Add rubocop for deprecated exception Exceptions::UnprocessableEntity. 2026-06-15 17:47:36 +02:00
Dusan Vuckovic
e67fb26562 Fixes #6187 - Assign follow-ups=No is enforced even if ticket is not re-opened. 2026-06-15 07:49:10 +01:00
Florian Liebe
c15ec10ab0 Maintenance: Update net-imap to 0.6.4.1. 2026-06-15 07:24:14 +02:00
Mantas Masalskis
aaebfb50f9 Fixes #6197 - Checklist template item order is not preserved when creating a... 2026-06-12 17:49:03 +03:00
Mantas Masalskis
dec97a362f Fixes #6198 - Custom package throws NameError: uninitialized constant... 2026-06-12 16:27:20 +03:00
Dusan Vuckovic
8e256a188c Fixes #6131 - Ticket summary doesn't refresh when switching to another sidebar and back to the summary. 2026-06-12 12:37:45 +01:00
Florian Liebe
cd63093007 Fixes #6194 - Unspecific error message after ticket creation if group has no mail address assigned. 2026-06-12 07:31:46 +02:00
Florian Liebe
69be23546a Maintenance: Update breaking changes file. 2026-06-11 10:44:41 +02:00
Weblate
72ecb15c21 Maintenance: Update translated files and catalog 2026-06-11 07:11:59 +01:00
Weblate
04af94f8c1 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Andi Chandler <andi@gowling.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Denys Kurochkin <denisku@wix.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
Co-authored-by: albanobattistella <albanobattistella@gmail.com>
Co-authored-by: kurtjaspaert-stack <kurt.jaspaert@gmail.com>
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/en_GB/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/hu/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/it/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/nl/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/uk/
Translation: Zammad/Zammad (pre-release version)
2026-06-11 06:06:51 +00:00
Rene Reimann
fbe64141e4 Maintenance: Improve ticket attachment download endpoint. 2026-06-10 17:23:59 +02:00
Dusan Vuckovic
177d289d2b Follow-up 1ada861e65 - Fixes #6159 - Note fields accept and display HTML tags. 2026-06-10 13:36:08 +01:00
Weblate
4751db0fbe Maintenance: Update translated files and catalog 2026-06-10 10:53:49 +01:00
Weblate
92500319d6 Maintenance: Translations update from translations.zammad.org.
Special thanks to the amazing translation team. You rock! 💪

Co-authored-by: Anntoo <jdoomm@gmail.com>
Co-authored-by: Denys Kurochkin <denisku@wix.com>
Co-authored-by: LomaxThomas <thomas.hartvig@lomax.dk>
Co-authored-by: Thomas Meijer <tvm@bizconnect.dk>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: holstix <holstix@live.dk>
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/da/
Translate-URL: https://translations.zammad.org/projects/zammad/zammad-stable/uk/
Translation: Zammad/Zammad (pre-release version)
2026-06-10 09:47:58 +00:00
Florian Liebe
47ae201a50 Maintenance: Update oauth to 1.1.6, oauth2 to 2.0.22. 2026-06-10 10:11:13 +02:00
Ben
fcdd5aa646 Maintenance: Desktop view - Beta UI: column headers SERP not accessible to screen readers 2026-06-10 08:35:15 +02:00
Dusan Vuckovic
1ada861e65 Fixes #6159 - Note fields accept and display HTML tags. 2026-06-09 17:10:29 +01:00
Florian Liebe
31615d48ae Maintenance: Add support for SLES 16. 2026-06-09 10:15:03 +02:00
Martin Gruner
d7de27ad0a Maintenance: Add support for Ubuntu 26.04 LTS 2026-06-09 10:05:25 +02:00
Florian Liebe
fa8c6bf6c0 Fixes #6193 - Validation when setting status to "pending reminder" without adding "pending time" attribute affects schedulers (next run). 2026-06-08 16:50:39 +02:00
Dominik Klein
ff593da81e Fixes #6185 - "Text modules" admin label is not in title case. 2026-06-08 13:50:15 +02:00
Florian Liebe
1f5d8846a4 Maintenance: Desktop View - Feedback> Milestone question is asked multiple times. 2026-06-08 12:06:23 +02:00
Florian Liebe
e8776c95d1 Maintenance: Update puma to 8.0.2. 2026-06-08 07:38:26 +02:00
Mantas Masalskis
f48696b1ec Maintenance: Desktop view - Signature which includes a image returned by form updater leads to peformance problems 2026-06-05 22:04:20 +03:00
Dominik Klein
88660cab71 Fixes #6183 - Writing Assistant Feedback Download empty. 2026-06-05 15:01:27 +02:00
Martin Gruner
3d761e3981 Maintenance: Add support for the RHEL 10 platform
Co-authored-by: Florian Liebe <fl@zammad.com>
2026-06-05 13:47:29 +02:00
Florian Liebe
ed8332190c Fixes #6182 - Today button of date picker is not visible in Light mode. 2026-06-05 13:40:58 +02:00
Florian Liebe
d617991a2b Fixes #6186 - SMTP (STARTTLS/TLS) error when sending e-mails. 2026-06-05 10:57:11 +02:00
Dominik Klein
41381d74aa Fixes #6162 - Beta UI: screen reader cannot handle the drop-down text modules. 2026-06-04 17:23:35 +02:00
Florian Liebe
285d020f3c Fixes #6180 - Sending .eml file attachments lead to broken S/MIME signature. 2026-06-04 09:35:54 +02:00
Florian Liebe
afb03e31a0 Maintenance: Update breaking changes file for Zammad 7.2. 2026-06-04 09:11:46 +02:00
Benjamin Scharf
8e49cc85c6 Fixes #6165: Desktop view - Beta UI: button for macros is not labeled 2026-06-03 08:11:19 +02:00
Dominik Klein
fd02b7adbf Fixes #6174 - Webhook> Date attributes do not work in custom payloads. 2026-06-02 08:25:15 +02:00
Rene Reimann
666f67d9b0 Maintenance: Improve long polling controller. 2026-06-01 08:15:21 +02:00
Mantas Masalskis
d2746e06de Fixes #6134 - Year Report has no results for October in Europe/London timezone 2026-05-29 13:26:38 +03:00
Florian Liebe
4f3873f141 Fixes #6092 - backup.sh: Improve scheduling control — skip immediate backup on container start and fix same-day scheduling. 2026-05-29 09:47:03 +02:00
Florian Liebe
01f6586a9a Maintenance: Update breaking changes file for Zammad 7.2. 2026-05-29 09:27:03 +02:00
Dominik Klein
ff041109c4 Maintenance: Log error messages from microsoft graph inside the http log (for more insides). 2026-05-28 23:19:20 +02:00
Dominik Klein
8b8dcc9d78 Fixes #6161 - Desktop view: Overviews mouseover ticket titles. 2026-05-28 23:17:02 +02:00
Mantas Masalskis
c9d09c26a3 Maintenance: Improve KB attachments handling 2026-05-28 14:00:19 +03:00
Florian Liebe
baba7471ce Maintenance: Remove Ubuntu 26.04. 2026-05-28 10:47:44 +02:00
Florian Liebe
65347c3a0e Maintenance: Improve OTRS import controller. 2026-05-27 21:35:38 +02:00
Florian Liebe
4fcf7c937a Maintenance: Improve handling of PGP signature verifying. 2026-05-27 20:39:42 +02:00
Florian Liebe
6709f4994d Fixes #6156 - debian package should not contain md5sum of logfile.
(cherry picked from commit 286f9be7f5)

286f9be7 Fixes #6156 - debian package should not contain md5sum of logfile.

Co-authored-by: Florian Liebe <fl@zammad.com>
2026-05-27 15:25:45 +02:00
Dusan Vuckovic
fe90397894 Fixes #6157 - Icons styles are broken in the public knowledge base. 2026-05-27 14:13:39 +01:00
Florian Liebe
7feb242648 Maintenance: Update gem dependencies. 2026-05-27 09:35:27 +02:00
Dominik Klein
f57794f98b Fixes #6132 - Zendesk Migration Creates Invalid screens Configuration for Ticket Fields. 2026-05-27 08:53:35 +02:00
Florian Liebe
3a3c03ceab Maintenance: Add support for Ubuntu 26.04. 2026-05-27 07:53:56 +02:00
Mantas Masalskis
fed1264c5b Fixes #6144 - ISO-2022-JP emails are not parsed correct if explicit charset is missing 2026-05-26 17:49:53 +03:00
Florian Liebe
c8f94c9705 Fixes #6120 - Ticket overview screen remains empty with a loading spinner after executing a bulk action. 2026-05-22 22:42:04 +02:00
Dusan Vuckovic
65051ec1c4 Fixes #6148 - Creating root-level Knowledge Base category fails with 'Couldn't find KnowledgeBase::Category without an ID'. 2026-05-22 20:07:53 +03:00
Dominik Klein
8d5b2f464c Maintenance: Audit UserAgent timeout values across call sites 2026-05-22 11:58:16 +02:00
Rene Reimann
590c39a5f7 Maintenance: Improve HTML sanitizer to remove unsafe Zammad API paths. 2026-05-22 08:58:51 +02:00
Rene Reimann
cde5d36b71 Maintenance: Improve taskbar handling in GraphQL context. 2026-05-21 21:34:29 +02:00
Dominik Klein
ade27ca7f5 Fixes #6130 - multi_tree_select Elasticsearch datatype change on reindex. 2026-05-21 21:02:36 +02:00
Mantas Masalskis
c0b88b7b88 Fixes #6141 - Email parsing crashes if content-type is malformed 2026-05-21 19:17:13 +03:00
Mantas Masalskis
b2f1568e27 Maintenance: Update dependency oauth2 to v2.0.20 2026-05-21 13:32:56 +03:00
Dominik Klein
5e21c42fc6 Fixes #6139 - Translation - user profile / devices - Field location not translated. 2026-05-21 09:42:56 +02:00
Dominik Klein
b2b0c82d20 Fixes #6146 - Report profiles are not sorted alphabetically. 2026-05-20 23:15:46 +02:00
Dominik Klein
05586ca634 Fixes #6140 - Populate the "External data source field" with any values via the API. 2026-05-20 21:49:17 +02:00
Florian Liebe
e5571a4f27 Maintenance: Deduplicate issue tracker controller code.
(cherry picked from commit 7053fff90626a003c91365a0002ed0796c56dacc)

26bfd681 Maintenance: Deduplicate issue tracker controller code.
e6311e8d Tidy specs, outsource another function
1181d291 show? -> agent_read_access?
6c51ffca show? -> agent_read_access? 2.0

Co-authored-by: Florian Liebe <fl@zammad.com>
2026-05-20 15:35:41 +02:00
Florian Liebe
162546ff79 Maintenance: Improve knowledge base answer suggestion transforming.
(cherry picked from commit f2b16d683c8ada4d831a68b50d091a02fee37245)

ee268f1a Maintenance: Improve knowlege base answer suggestion transforming.

Co-authored-by: Florian Liebe <fl@zammad.com>
2026-05-20 14:54:46 +02:00
Dusan Vuckovic
ec6a1c3fe0 Fixes #6126 - Updating ticket as closed via keyboard shortcut fails on an OTRS migrated system. 2026-05-20 12:48:04 +01:00
Mantas Masalskis
7099414ba9 Maintenance: Update dependency oauth2 to v2.0.19 2026-05-20 13:27:38 +03:00
Rene Reimann
9a9f4cf409 Maintenance: Improve fetching of avatars. 2026-05-20 10:03:36 +02:00
Dominik Klein
fff0170b7f Maintenance: Desktop view - Improve online notification output in popover. 2026-05-19 21:05:27 +02:00
Mantas Masalskis
de5dc41958 Maintenance: Improve Knowledge Base search when using granular permissions 2026-05-19 21:37:18 +03:00
Mantas Masalskis
198d156069 Fixes #6135 - KB with granular permissions does not show top-level category 2026-05-19 19:18:52 +03:00
Mantas Masalskis
d20dd0777c Fixes #5197 - Error on adding KB answers to category with granular permissions 2026-05-19 17:08:19 +03:00
Florian Liebe
7c8b6b8d03 Fixes #6115 - Header Based Actions not working in 7.0.
Co-authored-by: Rene Reimann <rr@zammad.com>
2026-05-19 14:15:34 +02:00
Dominik Klein
398c7ec90a Maintenance: Temporarily hide the AI knowledge base answer generation feature. 2026-05-19 13:16:51 +02:00
Mantas Masalskis
18c5e90601 Fixes #6136 - Complex emails signature detection crashes email parsing 2026-05-18 10:03:06 +03:00
Mantas Masalskis
0822edfb73 Fixes #5965 - Sessions metadata is not cleaned up when using ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS 2026-05-14 23:15:18 +03:00
Florian Liebe
9eeede7d56 Feature: Improvements for easy setup for Microsoft channels in SaaS context.
Co-authored-by: Dominik Klein <dk@zammad.com>
2026-05-13 22:55:32 +02:00
Dominik Klein
dfba299ad8 Fixes #6127 - In desktop view ticket.* variables in signature resolve to "-"... 2026-05-13 20:20:46 +02:00
Florian Liebe
ff20694223 Maintenance: Improve user context with token handling.
Co-authored-by: Rene Reimann <rr@zammad.com>
2026-05-08 12:02:46 +02:00
Mantas Masalskis
f5324cee41 Maintenance: Use UserAgent for Massenversand API 2026-05-08 09:58:39 +03:00
Florian Liebe
10cc4c2512 Maintenance: Improve HTML sanitizer to remove unsafe srcset entries. 2026-05-07 21:46:44 +02:00
Dominik Klein
a2f7757274 Maintenance: Improve client secret handling in microsoft base. 2026-05-07 10:53:24 +02:00
Dominik Klein
d5d8c9870e Maintenance: Add optional PKCE support to Microsoft OAuth flow. 2026-05-07 09:17:14 +02:00
Dominik Klein
de5733f6c9 Fixes #5710 - Mobile login "forgets" which ticket you wanted to go to after Microsoft Login 2026-05-06 21:43:33 +02:00
Mantas Masalskis
5d56b3e8fe Fixes #6108 - Attachment file size is wrong in the public KB answer 2026-05-06 17:41:21 +03:00
Mantas Masalskis
67b3844a5d Follow up - 5c64cd47 - Fixes #6108 - Attachment file size is wrong in the public KB answer 2026-05-06 17:40:52 +03:00
Stephan Eberle
f7fb3533cb Fixes #4820 - OAuth: redirect_uri lost after login when user has no active session. 2026-05-06 15:12:34 +02:00
Dominik Klein
3c02f5efd9 Maintenance: Improve current code style for generating state value inside the external credential layer. 2026-05-06 13:43:54 +02:00
Dominik Klein
5b57861d05 Fixes #6105 - Admin signshow / session bootstrap 500 when perform.ai.ai_agent.ai_agent_id is an Array (NoMethodError: to_i for Array) 2026-05-06 13:43:13 +02:00
Martin Gruner
ce2edd0f0c Maintenance: Update version information for 7.1.0 2026-05-06 08:06:04 +02:00
595 changed files with 97456 additions and 81144 deletions

View File

@@ -1,2 +1,2 @@
https://github.com/heroku/heroku-buildpack-nodejs.git#v343
https://github.com/pkgr/heroku-buildpack-ruby.git#v357-1
https://github.com/pkgr/heroku-buildpack-ruby.git#v361-1

View File

@@ -0,0 +1,36 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
module RuboCop
module Cop
module Zammad
# `Exceptions::UnprocessableEntity` is deprecated and will be removed in Zammad 8.0.
# Use `Exceptions::UnprocessableContent` instead.
#
# @example
# # bad
# raise Exceptions::UnprocessableEntity, 'message'
# rescue Exceptions::UnprocessableEntity => e
#
# # good
# raise Exceptions::UnprocessableContent, 'message'
# rescue Exceptions::UnprocessableContent => e
class ForbidUnprocessableEntity < Base
extend AutoCorrector
MSG = 'Use `Exceptions::UnprocessableContent` instead of the deprecated `Exceptions::UnprocessableEntity`.'.freeze
def_node_matcher :unprocessable_entity?, <<~PATTERN
(const (const {nil? cbase} :Exceptions) :UnprocessableEntity)
PATTERN
def on_const(node)
return if !unprocessable_entity?(node)
add_offense(node, message: MSG) do |corrector|
corrector.replace(node.loc.name, 'UnprocessableContent')
end
end
end
end
end
end

View File

@@ -16,6 +16,7 @@ require_relative 'cop/zammad/forbid_loofah_fragment'
require_relative 'cop/zammad/forbid_rand'
require_relative 'cop/zammad/forbid_redis_client'
require_relative 'cop/zammad/forbid_translatable_marker'
require_relative 'cop/zammad/forbid_unprocessable_entity'
require_relative 'cop/zammad/graphql_authorize_allow_public_access'
require_relative 'cop/zammad/graphql_authorize_requires_permission'
require_relative 'cop/zammad/graphql_authorize_disallowed'

View File

@@ -26,9 +26,12 @@ jobs:
- debian:12
- debian:13
- el:9
- el:10
- sles:15
- sles:16
- ubuntu:22.04
- ubuntu:24.04
- ubuntu:26.04
steps:
- uses: actions/checkout@v6
with:

5
.gitignore vendored
View File

@@ -132,3 +132,8 @@ schema.graphql
# Dart Sass compiled output (dartsass-rails)
/app/assets/builds/*
!/app/assets/builds/.keep
# AI Agents
/.agents/skills
/.claude/skills
/.claude/settings.local.json

View File

@@ -46,14 +46,32 @@ packager.io:ubuntu-24.04:
DISTRIBUTION: ubuntu
DISTRIBUTION_VERSION: '24.04'
packager.io:ubuntu-26.04:
extends: [.packager.io]
variables:
DISTRIBUTION: ubuntu
DISTRIBUTION_VERSION: '26.04'
packager.io:rhel-9:
extends: [.packager.io]
variables:
DISTRIBUTION: rhel
DISTRIBUTION_VERSION: '9'
packager.io:rhel-10:
extends: [.packager.io]
variables:
DISTRIBUTION: rhel
DISTRIBUTION_VERSION: '10'
packager.io:sles-15:
extends: [.packager.io]
variables:
DISTRIBUTION: sles
DISTRIBUTION_VERSION: '15'
packager.io:sles-16:
extends: [.packager.io]
variables:
DISTRIBUTION: sles
DISTRIBUTION_VERSION: '16.0'

View File

@@ -1,5 +1,5 @@
ARG DISTRIBUTION_VERSION
FROM docker.io/library/rockylinux:${DISTRIBUTION_VERSION}
FROM quay.io/centos/centos:${DISTRIBUTION_VERSION}
ARG DISTRIBUTION
ARG DISTRIBUTION_VERSION
ARG CI_COMMIT_REF_NAME

View File

@@ -20,7 +20,7 @@ zypper install -y elasticsearch
rpm --import https://go.packager.io/srv/rpm/zammad/zammad/gpg-key.asc
curl -o /etc/zypp/repos.d/zammad.repo \
https://go.packager.io/srv/zammad/zammad/${CI_COMMIT_REF_NAME}/installer/sles/${DISTRIBUTION_VERSION}.repo
"https://go.packager.io/srv/zammad/zammad/${CI_COMMIT_REF_NAME}/installer/sles/${DISTRIBUTION_VERSION%%.*}.repo"
zypper update -y
zypper install -y --download-only zammad

View File

@@ -14,8 +14,22 @@ targets:
- shared-mime-info
- redis
build_dependencies:
- https://download.fedoraproject.org/pub/epel/9/Everything/x86_64/Packages/i/imlib2-1.7.4-1.el9.x86_64.rpm
- https://download.fedoraproject.org/pub/epel/9/Everything/x86_64/Packages/i/imlib2-devel-1.7.4-1.el9.x86_64.rpm
# EPEL packages can only be installed after enabling the EPEL repo (see before.sh).
- epel-release
- shared-mime-info
centos-10:
dependencies:
- curl
- elasticsearch
- nginx
- postgresql-server
- epel-release
- imlib2
- shared-mime-info
- valkey
build_dependencies:
# EPEL packages can only be installed after enabling the EPEL repo (see before.sh).
- epel-release
- shared-mime-info
debian-11:
dependencies:
@@ -82,6 +96,19 @@ targets:
- libimlib2
- libimlib2-dev
- shared-mime-info
ubuntu-26.04:
dependencies:
- curl
- elasticsearch|elasticsearch-oss
- nginx|apache2
- postgresql
- libimlib2
- shared-mime-info
- redis-server
build_dependencies:
- libimlib2
- libimlib2-dev
- shared-mime-info
sles-15:
dependencies:
- curl
@@ -102,10 +129,34 @@ targets:
# Add packages required for build that are not in the official SLES repo.
# Direct URLs must be used since we cannot add repos on packager.io
# (would require a paid SLES account and credential management on packager.io).
- https://rpmfind.net/linux/opensuse/distribution/leap/15.6/repo/oss/x86_64/imlib2-1.4.10-1.28.x86_64.rpm
- https://rpmfind.net/linux/opensuse/distribution/leap/15.6/repo/oss/x86_64/imlib2-devel-1.4.10-1.28.x86_64.rpm
- https://rpmfind.net/linux/opensuse/distribution/leap/15.6/repo/oss/x86_64/imlib2-filters-1.4.10-1.28.x86_64.rpm
- https://rpmfind.net/linux/opensuse/distribution/leap/15.6/repo/oss/x86_64/libImlib2-1-1.4.10-1.28.x86_64.rpm
- https://download.opensuse.org/distribution/leap/15.6/repo/oss/x86_64/imlib2-1.4.10-1.28.x86_64.rpm
- https://download.opensuse.org/distribution/leap/15.6/repo/oss/x86_64/imlib2-devel-1.4.10-1.28.x86_64.rpm
- https://download.opensuse.org/distribution/leap/15.6/repo/oss/x86_64/imlib2-filters-1.4.10-1.28.x86_64.rpm
- https://download.opensuse.org/distribution/leap/15.6/repo/oss/x86_64/libImlib2-1-1.4.10-1.28.x86_64.rpm
- shared-mime-info
sles-16:
dependencies:
- curl
- elasticsearch
- nginx
- postgresql-server
- libImlib2-1
- imlib2
- shared-mime-info
- redis
# We need the fuser binary from the psmisc package to detect the running redis systemd service.
# Because on SUSE systems, this service is arbitrarily named (e.g.) redis@foobar.service
# So we cannot hardcode the service name as we do for other supported distros.
- psmisc
- procps
build_dependencies:
# Add packages required for build that are not in the official SLES repo.
# Direct URLs must be used since we cannot add repos on packager.io
# (would require a paid SLES account and credential management on packager.io).
- https://download.opensuse.org/distribution/leap/16.0/repo/oss/x86_64/imlib2-1.12.3-160000.3.2.x86_64.rpm
- https://download.opensuse.org/distribution/leap/16.0/repo/oss/x86_64/imlib2-devel-1.12.3-160000.3.2.x86_64.rpm
- https://download.opensuse.org/distribution/leap/16.0/repo/oss/x86_64/imlib2-filters-1.12.3-160000.3.2.x86_64.rpm
- https://download.opensuse.org/distribution/leap/16.0/repo/oss/x86_64/libImlib2-1-1.12.3-160000.3.2.x86_64.rpm
- shared-mime-info
before:
- contrib/packager.io/before.sh

View File

@@ -1,5 +1,26 @@
# Breaking Changes
## 7.1
### Elasticsearch 7 deprecated
Elasticsearch 7 has reached end of life and is now deprecated.
⚠️ A future version of Zammad now requires **Elasticsearch 8** or later.
### Calendar iCal feed must be a URL
Calendars can no longer be configured with a local file path as the iCal feed source. Only HTTP/HTTPS URLs are accepted.
Local file path support is now deprecated. In a future release of Zammad, this functionality will be removed.
⚠️ If you previously used a local `.ics`
file path, host the file on an HTTP server and update the calendar's iCal feed URL accordingly.
### Exceptions::UnprocessableEntity error is deprecated in favor of Exceptions::UnprocessableContent
Exceptions::UnprocessableEntity will be removed in Zammad 8.0
## 7.0
### MySQL support removed, database related application settings deprecated

View File

@@ -1,8 +1,8 @@
# Change Log for Zammad 7.1.x
# Change Log for Zammad 7.1.2
- [Release notes](https://zammad.com/en/releases/7-1-x)
- [Release notes](https://zammad.com/en/product/releases/7-1-2)
- [Breaking changes](BREAKING_CHANGES.md#71)
- [Implemented enhancements](https://github.com/zammad/zammad/issues?q=is%3Aclosed+milestone%3A7.1+(-type%3ABug+AND+-label%3Abug))
- [Closed bugs](https://github.com/zammad/zammad/issues?q=is%3Aclosed+milestone%3A7.1+(type%3ABug+OR+label%3Abug))
- [Full commit log](https://github.com/zammad/zammad/compare/6.5.2...7.1.x)
- [File tree](https://github.com/zammad/zammad/tree/7.1.x)
- [Full commit log](https://github.com/zammad/zammad/compare/7.1.1...7.1.2)
- [File tree](https://github.com/zammad/zammad/tree/7.1.2)

View File

@@ -32,29 +32,29 @@ GEM
simple_po_parser (~> 1.1.6)
aasm (5.5.2)
concurrent-ruby (~> 1.0)
actioncable (8.0.5)
actionpack (= 8.0.5)
activesupport (= 8.0.5)
actioncable (8.0.5.1)
actionpack (= 8.0.5.1)
activesupport (= 8.0.5.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.5)
actionpack (= 8.0.5)
activejob (= 8.0.5)
activerecord (= 8.0.5)
activestorage (= 8.0.5)
activesupport (= 8.0.5)
actionmailbox (8.0.5.1)
actionpack (= 8.0.5.1)
activejob (= 8.0.5.1)
activerecord (= 8.0.5.1)
activestorage (= 8.0.5.1)
activesupport (= 8.0.5.1)
mail (>= 2.8.0)
actionmailer (8.0.5)
actionpack (= 8.0.5)
actionview (= 8.0.5)
activejob (= 8.0.5)
activesupport (= 8.0.5)
actionmailer (8.0.5.1)
actionpack (= 8.0.5.1)
actionview (= 8.0.5.1)
activejob (= 8.0.5.1)
activesupport (= 8.0.5.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.5)
actionview (= 8.0.5)
activesupport (= 8.0.5)
actionpack (8.0.5.1)
actionview (= 8.0.5.1)
activesupport (= 8.0.5.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -62,27 +62,27 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.5)
actionpack (= 8.0.5)
activerecord (= 8.0.5)
activestorage (= 8.0.5)
activesupport (= 8.0.5)
actiontext (8.0.5.1)
actionpack (= 8.0.5.1)
activerecord (= 8.0.5.1)
activestorage (= 8.0.5.1)
activesupport (= 8.0.5.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.5)
activesupport (= 8.0.5)
actionview (8.0.5.1)
activesupport (= 8.0.5.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.5)
activesupport (= 8.0.5)
activejob (8.0.5.1)
activesupport (= 8.0.5.1)
globalid (>= 0.3.6)
activemodel (8.0.5)
activesupport (= 8.0.5)
activerecord (8.0.5)
activemodel (= 8.0.5)
activesupport (= 8.0.5)
activemodel (8.0.5.1)
activesupport (= 8.0.5.1)
activerecord (8.0.5.1)
activemodel (= 8.0.5.1)
activesupport (= 8.0.5.1)
timeout (>= 0.4.0)
activerecord-import (2.2.0)
activerecord (>= 4.2)
@@ -92,13 +92,13 @@ GEM
cgi (>= 0.3.6)
rack (>= 2.0.8, < 4)
railties (>= 7.0)
activestorage (8.0.5)
actionpack (= 8.0.5)
activejob (= 8.0.5)
activerecord (= 8.0.5)
activesupport (= 8.0.5)
activestorage (8.0.5.1)
actionpack (= 8.0.5.1)
activejob (= 8.0.5.1)
activerecord (= 8.0.5.1)
activesupport (= 8.0.5.1)
marcel (~> 1.0)
activesupport (8.0.5)
activesupport (8.0.5.1)
base64
benchmark (>= 0.3)
bigdecimal
@@ -123,6 +123,8 @@ GEM
ffi-compiler (~> 1.0)
ast (2.4.3)
attr_required (1.0.2)
auth-sanitizer (0.2.1)
version_gem (~> 1.1, >= 1.1.10)
autoprefixer-rails (10.4.21.0)
execjs (~> 2)
aws-eventstream (1.4.0)
@@ -192,7 +194,7 @@ GEM
coffee-script-source
execjs
coffee-script-source (1.12.2)
concurrent-ruby (1.3.6)
concurrent-ruby (1.3.7)
connection_pool (3.0.2)
console (1.34.3)
fiber-annotation
@@ -204,7 +206,7 @@ GEM
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
crass (1.0.7)
csv (3.3.5)
daemons (1.4.1)
dalli (5.0.2)
@@ -277,7 +279,7 @@ GEM
railties (>= 6.1.0)
faker (3.8.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.1)
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -288,7 +290,7 @@ GEM
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
faraday-net_http (3.4.4)
net-http (~> 0.5)
ffi (1.17.4)
ffi-compiler (1.4.2)
@@ -305,7 +307,7 @@ GEM
google-protobuf (4.34.1)
bigdecimal
rake (~> 13.3)
graphql (2.6.1)
graphql (2.6.7)
base64
fiber-storage
logger
@@ -350,7 +352,7 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.19.5)
json (2.19.9)
json-jwt (1.17.0)
activesupport (>= 4.2)
aes_key_wrap
@@ -358,7 +360,7 @@ GEM
bindata
faraday (~> 2.0)
faraday-follow_redirects
jwt (3.1.2)
jwt (3.2.0)
base64
koala (3.7.0)
addressable
@@ -382,12 +384,12 @@ GEM
logging (2.4.0)
little-plugger (~> 1.1)
multi_json (~> 1.14)
loofah (2.25.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
macaddr (1.7.2)
systemu (~> 2.6.5)
mail (2.9.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
@@ -406,16 +408,16 @@ GEM
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.27.0)
msgpack (1.8.0)
msgpack (1.8.3)
multi_json (1.21.0)
multi_xml (0.9.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
nestful (1.1.4)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.4)
net-imap (0.6.4.1)
date
net-protocol
net-ldap (0.20.0)
@@ -429,26 +431,31 @@ GEM
net-protocol
nio4r (2.7.5)
nkf (0.2.0)
nokogiri (1.19.3)
nokogiri (1.19.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nori (2.7.1)
bigdecimal
oauth (1.1.3)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
base64 (~> 0.1)
oauth-tty (~> 1.0, >= 1.0.6)
snaky_hash (~> 2.0)
cgi
oauth-tty (~> 1.0, >= 1.0.8)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oauth-tty (1.0.8)
auth-sanitizer (~> 0.1, >= 0.1.3)
cgi
version_gem (~> 1.1, >= 1.1.9)
oauth-tty (1.0.6)
version_gem (~> 1.1, >= 1.1.9)
oauth2 (2.0.18)
oauth2 (2.0.22)
auth-sanitizer (~> 0.2, >= 0.2.1)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.3)
version_gem (~> 1.1, >= 1.1.9)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
@@ -551,7 +558,7 @@ GEM
date
stringio
public_suffix (7.0.5)
puma (8.0.1)
puma (8.0.2)
nio4r (~> 2.0)
pundit (2.5.2)
activesupport (>= 3.0.0)
@@ -584,20 +591,20 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.0.5)
actioncable (= 8.0.5)
actionmailbox (= 8.0.5)
actionmailer (= 8.0.5)
actionpack (= 8.0.5)
actiontext (= 8.0.5)
actionview (= 8.0.5)
activejob (= 8.0.5)
activemodel (= 8.0.5)
activerecord (= 8.0.5)
activestorage (= 8.0.5)
activesupport (= 8.0.5)
rails (8.0.5.1)
actioncable (= 8.0.5.1)
actionmailbox (= 8.0.5.1)
actionmailer (= 8.0.5.1)
actionpack (= 8.0.5.1)
actiontext (= 8.0.5.1)
actionview (= 8.0.5.1)
activejob (= 8.0.5.1)
activemodel (= 8.0.5.1)
activerecord (= 8.0.5.1)
activestorage (= 8.0.5.1)
activesupport (= 8.0.5.1)
bundler (>= 1.15.0)
railties (= 8.0.5)
railties (= 8.0.5.1)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
@@ -606,12 +613,12 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.5)
actionpack (= 8.0.5)
activesupport (= 8.0.5)
railties (8.0.5.1)
actionpack (= 8.0.5.1)
activesupport (= 8.0.5.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -745,7 +752,7 @@ GEM
hashie
logger
slop (3.6.0)
snaky_hash (2.0.3)
snaky_hash (2.0.5)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sprockets (4.2.2)
@@ -805,7 +812,7 @@ GEM
activemodel (>= 3.0.0)
public_suffix
vcr (6.4.0)
version_gem (1.1.9)
version_gem (1.1.11)
viewpoint (1.2.0)
httpclient
logging
@@ -839,7 +846,7 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.8.0)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -852,7 +859,7 @@ GEM
rubyzip (>= 2.4.0, < 4.0)
xpath (3.2.0)
nokogiri (~> 1.8)
yard (0.9.43)
yard (0.9.44)
zeitwerk (2.7.5)
zendesk_api (3.1.1)
faraday (> 2.0.0)

View File

@@ -1 +1 @@
7.1.x
7.1.2

View File

@@ -1,6 +1,8 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
//= link_directory ../javascripts .js
//= link_directory ../builds .css
//= link font.css
//= link svg-dimensions.css
//= link application.js
//= link application.css
//= link application-print.css

View File

@@ -65,10 +65,17 @@ class TextTool extends App.ControllerAIFeatureBase
downloadFeedbackReport: (id) =>
text_tool = App.AITextTool.find(id)
url = "#{@apiPath}/ai/analytics/download/with_usages?filters[triggered_by_type]=AI::TextTool&filters[triggered_by_id]=#{id}"
# Only limit the report to feedback since the last reset if one happened;
# otherwise the report should collect everything.
if text_tool.analytics_stats_reset_at
url += "&filters[created_after]=#{text_tool.analytics_stats_reset_at}"
@ajax(
id: 'download_feedback_report'
type: 'GET'
url: "#{@apiPath}/ai/analytics/download/with_usages?filters[triggered_by_type]=AI::TextTool&filters[triggered_by_id]=#{id}&filters[created_after]=#{text_tool.analytics_stats_reset_at}"
url: url
processData: true
dataType: 'binary'
contentType: 'application/octet-stream'

View File

@@ -100,7 +100,7 @@ class App.GenericHistory extends App.ControllerModal
content = ''
if item.type is 'notification'
content = App.i18n.translateContent( "notification sent to '%s'", item.value_to )
if item.type is 'email'
else if item.type is 'email'
content = App.i18n.translateContent( "email sent to '%s'", item.value_to )
else if item.type is 'time_trigger_performed'
message = switch item.value_from

View File

@@ -0,0 +1,363 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class App.ChannelAccountOverview extends App.ControllerSubContent
events:
'click .js-new': 'new'
'click .js-admin-consent': 'adminConsent'
'click .js-delete': 'delete'
'click .js-reauthenticate': 'reauthenticate'
'click .js-configApp': 'configApp'
'click .js-disable': 'disable'
'click .js-enable': 'enable'
'click .js-channelGroupChange': 'groupChange'
'click .js-editInbound': 'editInbound'
'click .js-rollbackMigration': 'rollbackMigration'
'click .js-emailAddressNew': 'emailAddressNew'
'click .js-emailAddressEdit': 'emailAddressEdit'
'click .js-emailAddressDelete': 'emailAddressDelete'
constructor: ->
super
if !@channelKey
throw 'App.ChannelAccountOverview requires a channelKey param'
@interval(@load, 30000)
# URL builders -------------------------------------------------------------
indexUrl: ->
if @legacyUrls
"#{@apiPath}/channels_#{@channelKey}"
else
"#{@apiPath}/channels/admin/#{@channelKey}"
deleteUrl: (id) ->
if @legacyUrls
"#{@apiPath}/channels_#{@channelKey}"
else
"#{@apiPath}/channels/admin/#{@channelKey}/#{id}"
disableUrl: (id) ->
if @legacyUrls
"#{@apiPath}/channels_#{@channelKey}_disable"
else
"#{@apiPath}/channels/admin/#{@channelKey}/#{id}/disable"
enableUrl: (id) ->
if @legacyUrls
"#{@apiPath}/channels_#{@channelKey}_enable"
else
"#{@apiPath}/channels/admin/#{@channelKey}/#{id}/enable"
rollbackUrl: ->
"#{@apiPath}/channels_#{@channelKey}_rollback_migration"
linkAccountUrl: (queryString = '') ->
"#{@apiPath}/external_credentials/#{@channelKey}/link_account#{queryString}"
# Data loading & rendering -------------------------------------------------
load: (reset_channel_id = false) =>
if reset_channel_id
@channel_id = undefined
@navigate "#channels/#{@channelKey}"
@startLoading()
@ajax(
id: "#{@channelKey}_index"
type: 'GET'
url: @indexUrl()
processData: true
success: (data, status, xhr) =>
@stopLoading()
App.Collection.loadAssets(data.assets)
@callbackUrl = data.callback_url
@render(data)
)
shouldRenderIntro: (external_credential, data) ->
!external_credential
channelNeedsReauthentication: (channel, external_credential) ->
current_client_id = external_credential?.credentials?.client_id
return false if !current_client_id
current_client_id isnt channel.options?.auth?.client_id
render: (data) =>
# If no app is registered, show the intro page.
external_credential = App.ExternalCredential.findByAttribute('name', @externalCredentialName)
if @shouldRenderIntro(external_credential, data)
@html App.view("#{@viewNamespace}/index")()
if @channel_id
@configApp()
return
channels = []
for channel_id in data.channel_ids
channel = App.Channel.find(channel_id)
if channel.group_id
channel.group = App.Group.find(channel.group_id)
else
channel.group = '-'
email_addresses = App.EmailAddress.search(filter: { channel_id: channel.id })
channel.email_addresses = email_addresses
channel.needs_reauthentication = @channelNeedsReauthentication(channel, external_credential)
channels.push channel
# On a channel migration, redirect to the "Add Account" flow after
# the external credentials have been provided.
if @hasMigrationRedirect and @channel_id
item = App.Channel.find(@channel_id)
if item && item.area != @channelAreaName
@new()
return
not_used_email_addresses = []
for email_address_id in data.not_used_email_address_ids
not_used_email_addresses.push App.EmailAddress.find(email_address_id)
@html App.view('channel_account/list')(
channels: channels
external_credential: external_credential
not_used_email_addresses: not_used_email_addresses
has_admin_consent: @hasAdminConsent
has_mailbox_info: @hasMailboxInfo
rollback_button_first: @rollbackButtonFirst
)
# On a channel creation, auto-open the edit dialog so the user can
# adjust the inbound configuration. Skip for migrated channels — their
# configuration is assumed to be correct.
if @channel_id
item = App.Channel.find(@channel_id)
if item && item.area == @channelAreaName && item.options && item.options.backup_imap_classic is undefined && not @error_code
@editInbound(undefined, @channel_id, true, @hasInboundEditRedirect)
@channel_id = undefined
return if !@hasErrorCodeHandling
switch @error_code
when 'AADSTS65004'
@error_code = undefined
new App.AdminConsentInfo(container: @container, type: @channelKey)
when 'user_mismatch'
@error_code = undefined
new App.UserMismatchInfo(container: @container, type: @channelKey, item: item)
when 'duplicate_email_address'
@error_code = undefined
new App.DuplicateEmailAddressInfo(container: @container, type: @channelKey, emailAddress: if @param then decodeURIComponent(@param))
show: (params) =>
for key, value of params
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
@[key] = value
configApp: =>
new @appConfigClass(
container: @el.parents('.content')
callbackUrl: @callbackUrl
load: @load
)
# Actions ------------------------------------------------------------------
new: (e) =>
if @inboundNewClass
new @inboundNewClass(
container: @el.closest('.content')
)
return
window.location.href = @linkAccountUrl()
adminConsent: (e) =>
window.location.href = @linkAccountUrl('?prompt=consent')
delete: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
new App.ControllerConfirm(
message: __('Are you sure?')
buttonClass: 'btn--danger'
callback: =>
ajaxOptions =
id: "#{@channelKey}_delete"
type: 'DELETE'
url: @deleteUrl(id)
processData: true
success: =>
@load()
if @legacyUrls
ajaxOptions.data = JSON.stringify(id: id)
@ajax(ajaxOptions)
container: @el.closest('.content')
)
reauthenticate: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
window.location.href = @linkAccountUrl("?channel_id=#{id}")
disable: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
ajaxOptions =
id: "#{@channelKey}_disable"
type: 'POST'
url: @disableUrl(id)
processData: true
success: =>
@load()
if @legacyUrls
ajaxOptions.data = JSON.stringify(id: id)
@ajax(ajaxOptions)
enable: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
ajaxOptions =
id: "#{@channelKey}_enable"
type: 'POST'
url: @enableUrl(id)
processData: true
success: =>
@load()
if @legacyUrls
ajaxOptions.data = JSON.stringify(id: id)
@ajax(ajaxOptions)
editInbound: (e, channel_id, set_active, redirect = false) =>
if !channel_id
e.preventDefault()
channel_id = $(e.target).closest('.action').data('id')
item = App.Channel.find(channel_id)
new @inboundEditClass(
container: @el.closest('.content')
item: item
callback: @load
set_active: set_active
redirect: redirect
)
rollbackMigration: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: "#{@channelKey}_rollback_migration"
type: 'POST'
url: @rollbackUrl()
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
@notify
type: 'success'
msg: __('Rollback of channel migration succeeded!')
error: =>
@notify
type: 'error'
msg: __('Failed to roll back the migration of the channel!')
)
groupChange: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
item = App.Channel.find(id)
new @groupEditClass(
container: @el.closest('.content')
item: item
callback: @load
)
emailAddressNew: (e) =>
e.preventDefault()
channel_id = $(e.target).closest('.action').data('id')
params =
pageData:
object: __('Email Address')
genericObject: 'EmailAddress'
container: @el.closest('.content')
item:
channel_id: channel_id
callback: @load
if @emailAddressNewStickyAlerts
params.stickyAlerts = @emailAddressNewStickyAlerts
new App.ControllerGenericNew(params)
emailAddressEdit: (e) =>
e.preventDefault()
id = $(e.target).closest('li').data('id')
new App.ControllerGenericEdit(
pageData:
object: __('Email Address')
genericObject: 'EmailAddress'
container: @el.closest('.content')
id: id
callback: @load
)
emailAddressDelete: (e) =>
e.preventDefault()
id = $(e.target).closest('li').data('id')
item = App.EmailAddress.find(id)
new App.ControllerGenericDestroyConfirm(
item: item
container: @el.closest('.content')
callback: @load
)
class App.AdminConsentInfo extends App.ControllerModal
buttonClose: true
small: true
buttonSubmit: __('Close')
head: __('Admin Consent')
content: ->
App.view('channel_account/admin_consent')()
onSubmit: =>
@close()
onClosed: =>
return if not @type
@navigate "#channels/#{@type}"
class App.UserMismatchInfo extends App.ControllerModal
buttonClose: true
small: true
buttonSubmit: __('Close')
head: __('User Mismatch')
content: ->
App.view('channel_account/user_mismatch')(item: @item)
onSubmit: =>
@close()
onClosed: =>
return if not @type
@navigate "#channels/#{@type}"
class App.DuplicateEmailAddressInfo extends App.ControllerModal
buttonClose: true
small: true
buttonSubmit: __('Close')
head: __('Duplicate Email Address')
content: ->
App.view('channel_account/duplicate_email_address')(emailAddress: @emailAddress)
onSubmit: =>
@close()
onClosed: =>
return if not @type
@navigate "#channels/#{@type}"

View File

@@ -10,7 +10,21 @@ class App.ChannelGoogle extends App.ControllerTabs
{
name: __('Accounts'),
target: 'c-account',
controller: ChannelAccountOverview,
controller: App.ChannelAccountOverview,
params:
channelKey: 'google'
channelAreaName: 'Google::Account'
externalCredentialName: 'google'
viewNamespace: 'google'
legacyUrls: true
hasAdminConsent: false
hasRollbackMigration: true
hasErrorCodeHandling: false
hasMigrationRedirect: true
hasInboundEditRedirect: false
appConfigClass: AppConfig
groupEditClass: ChannelGroupEdit
inboundEditClass: ChannelInboundEdit
},
{
name: __('Filter'),
@@ -32,242 +46,6 @@ class App.ChannelGoogle extends App.ControllerTabs
@render()
class ChannelAccountOverview extends App.ControllerSubContent
@requiredPermission: 'admin.channel_google'
events:
'click .js-new': 'new'
'click .js-delete': 'delete'
'click .js-reauthenticate': 'reauthenticate'
'click .js-configApp': 'configApp'
'click .js-disable': 'disable'
'click .js-enable': 'enable'
'click .js-channelGroupChange': 'groupChange'
'click .js-editInbound': 'editInbound'
'click .js-rollbackMigration': 'rollbackMigration'
'click .js-emailAddressNew': 'emailAddressNew'
'click .js-emailAddressEdit': 'emailAddressEdit'
'click .js-emailAddressDelete': 'emailAddressDelete',
constructor: ->
super
@interval(@load, 30000)
@load()
load: (reset_channel_id = false) =>
if reset_channel_id
@channel_id = undefined
@navigate '#channels/google'
@startLoading()
@ajax(
id: 'google_index'
type: 'GET'
url: "#{@apiPath}/channels_google"
processData: true
success: (data, status, xhr) =>
@stopLoading()
App.Collection.loadAssets(data.assets)
@callbackUrl = data.callback_url
@render(data)
)
render: (data) =>
# if no google app is registered, show intro
external_credential = App.ExternalCredential.findByAttribute('name', 'google')
if !external_credential
@html App.view('google/index')()
if @channel_id
@configApp()
return
channels = []
for channel_id in data.channel_ids
channel = App.Channel.find(channel_id)
if channel.group_id
channel.group = App.Group.find(channel.group_id)
else
channel.group = '-'
email_addresses = App.EmailAddress.search(filter: { channel_id: channel.id })
channel.email_addresses = email_addresses
channels.push channel
# on a channel migration we need to auto redirect
# the user to the "Add Account" functionality after
# the filled up the external credentials
if @channel_id
item = App.Channel.find(@channel_id)
if item && item.area != 'Google::Account'
@new()
return
# get all unlinked email addresses
not_used_email_addresses = []
for email_address_id in data.not_used_email_address_ids
not_used_email_addresses.push App.EmailAddress.find(email_address_id)
@html App.view('google/list')(
channels: channels
external_credential: external_credential
not_used_email_addresses: not_used_email_addresses
)
# on a channel creation we will auto open the edit
# dialog after the redirect back to zammad to optional
# change the inbound configuration, but not for
# migrated channel because we guess that the inbound configuration
# is already correct for them.
if @channel_id
item = App.Channel.find(@channel_id)
if item && item.area == 'Google::Account' && item.options && item.options.backup_imap_classic is undefined
@editInbound(undefined, @channel_id, true)
@channel_id = undefined
show: (params) =>
for key, value of params
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
@[key] = value
configApp: =>
new AppConfig(
container: @el.parents('.content')
callbackUrl: @callbackUrl
load: @load
)
new: (e) ->
window.location.href = "#{@apiPath}/external_credentials/google/link_account"
delete: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
new App.ControllerConfirm(
message: __('Are you sure?')
buttonClass: 'btn--danger'
callback: =>
@ajax(
id: 'google_delete'
type: 'DELETE'
url: "#{@apiPath}/channels_google"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
container: @el.closest('.content')
)
reauthenticate: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
window.location.href = "#{@apiPath}/external_credentials/google/link_account?channel_id=#{id}"
disable: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'google_disable'
type: 'POST'
url: "#{@apiPath}/channels_google_disable"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
enable: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'google_enable'
type: 'POST'
url: "#{@apiPath}/channels_google_enable"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
editInbound: (e, channel_id, set_active) =>
if !channel_id
e.preventDefault()
channel_id = $(e.target).closest('.action').data('id')
item = App.Channel.find(channel_id)
new ChannelInboundEdit(
container: @el.closest('.content')
item: item
callback: @load
set_active: set_active
)
rollbackMigration: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'google_rollback_migration'
type: 'POST'
url: "#{@apiPath}/channels_google_rollback_migration"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
@notify
type: 'success'
msg: __('Rollback of channel migration succeeded!')
error: (data) =>
@notify
type: 'error'
msg: __('Failed to roll back the migration of the channel!')
)
groupChange: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
item = App.Channel.find(id)
new ChannelGroupEdit(
container: @el.closest('.content')
item: item
callback: @load
)
emailAddressNew: (e) =>
e.preventDefault()
channel_id = $(e.target).closest('.action').data('id')
new App.ControllerGenericNew(
pageData:
object: __('Email Address')
genericObject: 'EmailAddress'
container: @el.closest('.content')
item:
channel_id: channel_id
callback: @load
)
emailAddressEdit: (e) =>
e.preventDefault()
id = $(e.target).closest('li').data('id')
new App.ControllerGenericEdit(
pageData:
object: __('Email Address')
genericObject: 'EmailAddress'
container: @el.closest('.content')
id: id
callback: @load
)
emailAddressDelete: (e) =>
e.preventDefault()
id = $(e.target).closest('li').data('id')
item = App.EmailAddress.find(id)
new App.ControllerGenericDestroyConfirm(
item: item
container: @el.closest('.content')
callback: @load
)
class ChannelInboundEdit extends App.ControllerModal
@include App.DestinationGroupEmailAddressesMixin

View File

@@ -10,7 +10,21 @@ class App.ChannelMicrosoft365 extends App.ControllerTabs
{
name: __('Accounts'),
target: 'c-account',
controller: ChannelAccountOverview,
controller: App.ChannelAccountOverview,
params:
channelKey: 'microsoft365'
channelAreaName: 'Microsoft365::Account'
externalCredentialName: 'microsoft365'
viewNamespace: 'microsoft365'
legacyUrls: true
hasAdminConsent: true
hasRollbackMigration: true
hasErrorCodeHandling: true
hasMigrationRedirect: true
hasInboundEditRedirect: true
appConfigClass: AppConfig
groupEditClass: ChannelGroupEdit
inboundEditClass: ChannelInboundEdit
},
{
name: __('Filter'),
@@ -37,259 +51,6 @@ class App.ChannelMicrosoft365 extends App.ControllerTabs
@render()
class ChannelAccountOverview extends App.ControllerSubContent
@requiredPermission: 'admin.channel_microsoft365'
events:
'click .js-new': 'new'
'click .js-admin-consent': 'adminConsent'
'click .js-delete': 'delete'
'click .js-reauthenticate': 'reauthenticate'
'click .js-configApp': 'configApp'
'click .js-disable': 'disable'
'click .js-enable': 'enable'
'click .js-channelGroupChange': 'groupChange'
'click .js-editInbound': 'editInbound'
'click .js-rollbackMigration': 'rollbackMigration'
'click .js-emailAddressNew': 'emailAddressNew'
'click .js-emailAddressEdit': 'emailAddressEdit'
'click .js-emailAddressDelete': 'emailAddressDelete',
constructor: ->
super
@interval(@load, 30000)
@load()
load: (reset_channel_id = false) =>
if reset_channel_id
@channel_id = undefined
@navigate '#channels/microsoft365'
@startLoading()
@ajax(
id: 'microsoft365_index'
type: 'GET'
url: "#{@apiPath}/channels_microsoft365"
processData: true
success: (data, status, xhr) =>
@stopLoading()
App.Collection.loadAssets(data.assets)
@callbackUrl = data.callback_url
@render(data)
)
render: (data) =>
# if no microsoft365 app is registered, show intro
external_credential = App.ExternalCredential.findByAttribute('name', 'microsoft365')
if !external_credential
@html App.view('microsoft365/index')()
if @channel_id
@configApp()
return
channels = []
for channel_id in data.channel_ids
channel = App.Channel.find(channel_id)
if channel.group_id
channel.group = App.Group.find(channel.group_id)
else
channel.group = '-'
email_addresses = App.EmailAddress.search(filter: { channel_id: channel.id })
channel.email_addresses = email_addresses
channels.push channel
# on a channel migration we need to auto redirect
# the user to the "Add Account" functionality after
# the filled up the external credentials
if @channel_id
item = App.Channel.find(@channel_id)
if item && item.area != 'Microsoft365::Account'
@new()
return
# get all unlinked email addresses
not_used_email_addresses = []
for email_address_id in data.not_used_email_address_ids
not_used_email_addresses.push App.EmailAddress.find(email_address_id)
@html App.view('microsoft365/list')(
channels: channels
external_credential: external_credential
not_used_email_addresses: not_used_email_addresses
)
# on a channel creation we will auto open the edit
# dialog after the redirect back to zammad to optional
# change the inbound configuration, but not for
# migrated channel because we guess that the inbound configuration
# is already correct for them.
if @channel_id
item = App.Channel.find(@channel_id)
if item && item.area == 'Microsoft365::Account' && item.options && item.options.backup_imap_classic is undefined && not @error_code
@editInbound(undefined, @channel_id, true, true)
@channel_id = undefined
if @error_code is 'AADSTS65004'
@error_code = undefined
new App.AdminConsentInfo(container: @container, type: 'microsoft365')
if @error_code is 'user_mismatch'
@error_code = undefined
new App.UserMismatchInfo(container: @container, type: 'microsoft365', item: item)
if @error_code is 'duplicate_email_address'
@error_code = undefined
new App.DuplicateEmailAddressInfo(container: @container, type: 'microsoft365', emailAddress: if @param then decodeURIComponent(@param))
show: (params) =>
for key, value of params
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
@[key] = value
configApp: =>
new AppConfig(
container: @el.parents('.content')
callbackUrl: @callbackUrl
load: @load
)
new: (e) ->
window.location.href = "#{@apiPath}/external_credentials/microsoft365/link_account"
adminConsent: (e) ->
window.location.href = "#{@apiPath}/external_credentials/microsoft365/link_account?prompt=consent"
delete: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
new App.ControllerConfirm(
message: __('Are you sure?')
buttonClass: 'btn--danger'
callback: =>
@ajax(
id: 'microsoft365_delete'
type: 'DELETE'
url: "#{@apiPath}/channels_microsoft365"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
container: @el.closest('.content')
)
reauthenticate: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
window.location.href = "#{@apiPath}/external_credentials/microsoft365/link_account?channel_id=#{id}"
disable: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'microsoft365_disable'
type: 'POST'
url: "#{@apiPath}/channels_microsoft365_disable"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
enable: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'microsoft365_enable'
type: 'POST'
url: "#{@apiPath}/channels_microsoft365_enable"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
editInbound: (e, channel_id, set_active, redirect = false) =>
if !channel_id
e.preventDefault()
channel_id = $(e.target).closest('.action').data('id')
item = App.Channel.find(channel_id)
new ChannelInboundEdit(
container: @el.closest('.content')
item: item
callback: @load
set_active: set_active
redirect: redirect
)
rollbackMigration: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'microsoft365_rollback_migration'
type: 'POST'
url: "#{@apiPath}/channels_microsoft365_rollback_migration"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
@notify
type: 'success'
msg: __('Rollback of channel migration succeeded!')
error: (data) =>
@notify
type: 'error'
msg: __('Failed to roll back the migration of the channel!')
)
groupChange: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
item = App.Channel.find(id)
new ChannelGroupEdit(
container: @el.closest('.content')
item: item
callback: @load
)
emailAddressNew: (e) =>
e.preventDefault()
channel_id = $(e.target).closest('.action').data('id')
new App.ControllerGenericNew(
pageData:
object: __('Email Address')
genericObject: 'EmailAddress'
container: @el.closest('.content')
item:
channel_id: channel_id
callback: @load
)
emailAddressEdit: (e) =>
e.preventDefault()
id = $(e.target).closest('li').data('id')
new App.ControllerGenericEdit(
pageData:
object: __('Email Address')
genericObject: 'EmailAddress'
container: @el.closest('.content')
id: id
callback: @load
)
emailAddressDelete: (e) =>
e.preventDefault()
id = $(e.target).closest('li').data('id')
item = App.EmailAddress.find(id)
new App.ControllerGenericDestroyConfirm(
item: item
container: @el.closest('.content')
callback: @load
)
class ChannelInboundEdit extends App.ControllerModal
@include App.DestinationGroupEmailAddressesMixin
@@ -503,55 +264,4 @@ class AppConfig extends App.ControllerModal
@el.find('.alert--danger').removeClass('hide').text(data.error || __('App could not be verified.'))
)
class App.AdminConsentInfo extends App.ControllerModal
buttonClose: true
small: true
buttonSubmit: __('Close')
head: __('Admin Consent')
content: ->
App.view('microsoft365/admin_consent')()
onSubmit: =>
@close()
onClosed: =>
return if not @type
@navigate "#channels/#{@type}"
class App.UserMismatchInfo extends App.ControllerModal
buttonClose: true
small: true
buttonSubmit: __('Close')
head: __('User Mismatch')
content: ->
App.view('microsoft365/user_mismatch')(item: @item)
onSubmit: =>
@close()
onClosed: =>
return if not @type
@navigate "#channels/#{@type}"
class App.DuplicateEmailAddressInfo extends App.ControllerModal
buttonClose: true
small: true
buttonSubmit: __('Close')
head: __('Duplicate Email Address')
content: ->
App.view('microsoft365/duplicate_email_address')(emailAddress: @emailAddress)
onSubmit: =>
@close()
onClosed: =>
return if not @type
@navigate "#channels/#{@type}"
App.Config.set('microsoft365', { prio: 5200, name: __('Microsoft 365 IMAP Email'), parent: '#channels', target: '#channels/microsoft365', controller: App.ChannelMicrosoft365, permission: ['admin.channel_microsoft365'] }, 'NavBarAdmin')

View File

@@ -12,7 +12,31 @@ class App.ChannelMicrosoftGraph extends App.ControllerTabs
{
name: __('Accounts'),
target: 'c-account',
controller: ChannelAccountOverview,
controller: App.ChannelAccountOverview,
params:
channelKey: 'microsoft_graph'
channelAreaName: 'MicrosoftGraph::Account'
externalCredentialName: 'microsoft_graph'
viewNamespace: 'microsoft_graph'
hasAdminConsent: true
hasRollbackMigration: false
hasMailboxInfo: true
hasErrorCodeHandling: true
hasMigrationRedirect: false
hasInboundEditRedirect: true
appConfigClass: AppConfig
groupEditClass: ChannelGroupEdit
inboundEditClass: ChannelInboundEdit
inboundNewClass: ChannelInboundNew
emailAddressNewStickyAlerts: [
[
'warning',
[
__('Please note that email aliases have to be configured on the Microsoft 365 side beforehand. %l'),
'https://admin-docs.zammad.org/microsoft365-graph-account-aliases'
]
]
]
},
{
name: __('Filter'),
@@ -35,237 +59,6 @@ class App.ChannelMicrosoftGraph extends App.ControllerTabs
@render()
class ChannelAccountOverview extends App.ControllerSubContent
@requiredPermission: 'admin.channel_microsoft_graph'
events:
'click .js-new': 'new'
'click .js-admin-consent': 'adminConsent'
'click .js-editInbound': 'editInbound'
'click .js-configApp': 'configApp'
'click .js-delete': 'delete'
'click .js-reauthenticate': 'reauthenticate'
'click .js-disable': 'disable'
'click .js-enable': 'enable'
'click .js-emailAddressNew': 'emailAddressNew'
'click .js-emailAddressEdit': 'emailAddressEdit'
'click .js-emailAddressDelete': 'emailAddressDelete',
'click .js-channelGroupChange': 'groupChange'
constructor: ->
super
@interval(@load, 30000)
@load()
load: (reset_channel_id = false) =>
if reset_channel_id
@channel_id = undefined
@navigate '#channels/microsoft_graph'
@startLoading()
@ajax(
id: 'microsoft_graph_index'
type: 'GET'
url: "#{@apiPath}/channels/admin/microsoft_graph"
processData: true
success: (data, status, xhr) =>
@stopLoading()
App.Collection.loadAssets(data.assets)
@callbackUrl = data.callback_url
@render(data)
)
new: (e) ->
new ChannelInboundNew(
container: @el.closest('.content')
)
adminConsent: (e) ->
window.location.href = "#{@apiPath}/external_credentials/microsoft_graph/link_account?prompt=consent"
delete: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
new App.ControllerConfirm(
message: __('Are you sure?')
buttonClass: 'btn--danger'
callback: =>
@ajax(
id: 'microsoft_graph_delete'
type: 'DELETE'
url: "#{@apiPath}/channels/admin/microsoft_graph/#{id}"
processData: true
success: =>
@load()
)
container: @el.closest('.content')
)
emailAddressNew: (e) =>
e.preventDefault()
channel_id = $(e.target).closest('.action').data('id')
new App.ControllerGenericNew(
pageData:
object: __('Email Address')
genericObject: 'EmailAddress'
container: @el.closest('.content')
item:
channel_id: channel_id
callback: @load
stickyAlerts: [
[
'warning',
[
__('Please note that email aliases have to be configured on the Microsoft 365 side beforehand. %l'),
'https://admin-docs.zammad.org/microsoft365-graph-account-aliases'
]
]
]
)
emailAddressEdit: (e) =>
e.preventDefault()
id = $(e.target).closest('li').data('id')
modal = new App.ControllerGenericEdit(
pageData:
object: __('Email Address')
genericObject: 'EmailAddress'
container: @el.closest('.content')
id: id
callback: @load
)
emailAddressDelete: (e) =>
e.preventDefault()
id = $(e.target).closest('li').data('id')
item = App.EmailAddress.find(id)
new App.ControllerGenericDestroyConfirm(
item: item
container: @el.closest('.content')
callback: @load
)
groupChange: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
item = App.Channel.find(id)
new ChannelGroupEdit(
container: @el.closest('.content')
item: item
callback: @load
)
reauthenticate: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
window.location.href = "#{@apiPath}/external_credentials/microsoft_graph/link_account?channel_id=#{id}"
disable: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'microsoft_graph_disable'
type: 'POST'
url: "#{@apiPath}/channels/admin/microsoft_graph/#{id}/disable"
processData: true
success: =>
@load()
)
enable: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'microsoft_graph_enable'
type: 'POST'
url: "#{@apiPath}/channels/admin/microsoft_graph/#{id}/enable"
processData: true
success: =>
@load()
)
render: (data) =>
# if no microsoft graph app is registered, show intro
external_credential = App.ExternalCredential.findByAttribute('name', 'microsoft_graph')
if !external_credential
@html App.view('microsoft_graph/index')()
if @channel_id
@configApp()
return
channels = []
for channel_id in data.channel_ids
channel = App.Channel.find(channel_id)
if channel.group_id
channel.group = App.Group.find(channel.group_id)
else
channel.group = '-'
email_addresses = App.EmailAddress.search(filter: { channel_id: channel.id })
channel.email_addresses = email_addresses
channels.push channel
# get all unlinked email addresses
not_used_email_addresses = []
for email_address_id in data.not_used_email_address_ids
not_used_email_addresses.push App.EmailAddress.find(email_address_id)
@html App.view('microsoft_graph/list')(
channels: channels
external_credential: external_credential
not_used_email_addresses: not_used_email_addresses
)
# On a channel creation we will auto open the edit dialog after the redirect back to zammad to optional
# change the inbound configuration.
if @channel_id
item = App.Channel.find(@channel_id)
if item && item.area == 'MicrosoftGraph::Account' && item.options && item.options.backup_imap_classic is undefined && not @error_code
@editInbound(undefined, @channel_id, true, true)
@channel_id = undefined
if @error_code is 'AADSTS65004'
@error_code = undefined
new App.AdminConsentInfo(container: @container, type: 'microsoft_graph')
if @error_code is 'user_mismatch'
@error_code = undefined
new App.UserMismatchInfo(container: @container, type: 'microsoft_graph', item: item)
if @error_code is 'duplicate_email_address'
@error_code = undefined
new App.DuplicateEmailAddressInfo(container: @container, type: 'microsoft_graph', emailAddress: if @param then decodeURIComponent(@param))
show: (params) =>
for key, value of params
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
@[key] = value
configApp: =>
new AppConfig(
container: @el.parents('.content')
callbackUrl: @callbackUrl
load: @load
)
editInbound: (e, channel_id, set_active, redirect = false) =>
if !channel_id
e.preventDefault()
channel_id = $(e.target).closest('.action').data('id')
item = App.Channel.find(channel_id)
new ChannelInboundEdit(
container: @el.closest('.content')
item: item
callback: @load
set_active: set_active
redirect: redirect
)
class ChannelGroupEdit extends App.ControllerModal
@include App.DestinationGroupEmailAddressesMixin

View File

@@ -10,7 +10,7 @@ App.Config.set('User', {
class: 'user'
}, 'NavBarRight' )
App.Config.set('Admin', { prio: 9000, parent: '', name: __('Admin'), translate: true, target: '#manage', icon: 'cog', permission: ['admin.*'] }, 'NavBarRight')
App.Config.set('Admin', { prio: 9000, parent: '', name: __('Administration'), translate: true, target: '#manage', icon: 'cog', permission: ['admin.*'] }, 'NavBarRight')
App.Config.set('New', { prio: 20000, parent: '', name: __('New'), translate: true, target: '#new', class: 'add', icon: 'plus' }, 'NavBarRight')
App.Config.set('Misc', { prio: 90000, parent: '', name: __('Tools'), translate: true, target: '#tools', child: true, class: 'tools' }, 'NavBar')
# only for testing

View File

@@ -516,7 +516,19 @@ App.Config.set(
App.Event.trigger('keyboard_shortcuts_close')
return if !$('.active.content .edit').get(0)
$('.active.content .edit [name="state_id"]').val(4).trigger('change')
default_close = App.TicketState.findByAttribute('default_close', true)
return if not default_close
# In case of a pending state, pre-fill pending time with a default value (e.g. 3 days from now),
# in order to prevent the validation error of missing pending time.
# Do this before setting the state itself, so we can expect just a single core workflow run.
if default_close.next_state_id
now = new Date()
pending_time = new Date()
pending_time.setDate(now.getDate() + 3)
$('.active.content .edit [name="{datetime}pending_time"]').val(pending_time.toISOString())
$('.active.content .edit [name="state_id"]').val(default_close.id).trigger('change')
# Waiting for the core workflow to be executed (for example, the pending time field is no longer visible).
interval = setInterval ->

View File

@@ -16,7 +16,19 @@ class App.MobileDetection
target = '/mobile'
if window.location.hash
target += "/#{window.location.hash}"
hash = window.location.hash.slice(1)
if hash and hash isnt '/'
if App.Session.get('id')
# Authenticated user: preserve hash for the mobile app's redirectGuard to handle.
target += "/#{window.location.hash}"
else
# Unauthenticated user: redirect directly to the mobile login page with the
# intended destination as a redirect parameter. This ensures the redirect
# survives the SSO authentication flow, since the legacy router may overwrite
# a hash-based URL before the browser completes the navigation.
redirectPath = encodeURIComponent("/#{hash}")
target = "/mobile/login?redirect=#{redirectPath}"
window.location.href = target

View File

@@ -236,7 +236,7 @@ class App.UiElement.object_attribute_options_context extends Spine.Module
_.map(related_object_attribute.options, (label, value) ->
{
value: value
label: if related_object_attribute.translate then App.i18n.translateInline(label) else label
label: if related_object_attribute.translate then App.i18n.translatePlain(label) else label
}
)
@@ -273,7 +273,7 @@ class App.UiElement.object_attribute_options_context extends Spine.Module
label = if isTree
App.TokenHelper.computeNameValue(option.value, isTranslated)
else if isTranslated
App.i18n.translateInline(option.name or option.value)
App.i18n.translatePlain(option.name or option.value)
else
option.name or option.value

View File

@@ -0,0 +1,5 @@
class App.AfterAuthDoorkeeperReturnTo extends App.Controller
constructor: (params) ->
super(params)
url = params.data?.url
window.location.href = url if typeof url is 'string' and url.length > 0

View File

@@ -754,6 +754,11 @@ class App.TicketCreate extends App.Controller
target: e.target
)
# prevent email ticket submission when the selected group has no email address
if @groupEmailMissingForEmailOut()
errors = errors || {}
errors['group_id'] = __('This group has no email address configured for outgoing communication.')
# show errors in form
if !_.isEmpty(errors)
@log 'error', errors
@@ -834,6 +839,14 @@ class App.TicketCreate extends App.Controller
return
@formEnable(@$('.js-submit'), 'button')
groupEmailMissingForEmailOut: =>
return false if @currentChannel() isnt 'email-out'
params = @params()
return false unless params.group_id
group = App.Group.find(params.group_id)
group? && !group.email_address_id
class Router extends App.ControllerPermanent
@requiredPermission: 'ticket.agent'
constructor: (params) ->

View File

@@ -94,7 +94,9 @@ class App.ArticleViewItem extends App.ControllerObserver
links.push link
# prepare html body
if article.content_type is 'text/html'
if article.body_rendering_error
article['html'] = App.Utils.text2html(App.i18n.translatePlain(article.body))
else if article.content_type is 'text/html'
body = article.body
domBody = $('<div>').html(body)

View File

@@ -107,11 +107,12 @@ class ImportOtrs extends App.ControllerWizardFullScreen
url: "#{@apiPath}/import/otrs/import_check"
processData: true
success: (data, status, xhr) =>
if data.result is 'ok'
if data.result? and data.result is 'ok'
@startMigration()
return
for issue in data.issues
@$(".js-#{issue}").removeClass('hide')
if data.issues?
for issue in data.issues
@$(".js-#{issue}").removeClass('hide')
@showImportNotice()
)

View File

@@ -64,7 +64,11 @@ class App.KnowledgeBaseReaderListContainer.Categories extends App.KnowledgeBaseR
#
if !@isEditor
items = items.filter (elem) => elem.visibleInternally(@kb_locale)
items = items.filter (elem) =>
if elem.access(@kb_locale) == 'editor'
true
else
elem.visibleInternally(@kb_locale)
items

View File

@@ -1,6 +1,6 @@
class TextModule extends App.ControllerSubContent
@requiredPermission: 'admin.text_module'
header: __('Text modules')
header: __('Text Modules')
constructor: ->
super
@@ -20,7 +20,7 @@ class TextModule extends App.ControllerSubContent
pageData:
home: 'text_modules'
object: __('Text module')
objects: __('Text modules')
objects: __('Text Modules')
searchPlaceholder: __('Search for text modules')
pagerAjax: true
pagerBaseUrl: '#manage/text_modules/'
@@ -44,4 +44,4 @@ class TextModule extends App.ControllerSubContent
@genericController.paginate(@page || 1, params)
App.Config.set('TextModule', { prio: 2300, name: __('Text modules'), parent: '#manage', target: '#manage/text_modules', controller: TextModule, permission: ['admin.text_module'] }, 'NavBarAdmin')
App.Config.set('TextModule', { prio: 2300, name: __('Text Modules'), parent: '#manage', target: '#manage/text_modules', controller: TextModule, permission: ['admin.text_module'] }, 'NavBarAdmin')

View File

@@ -44,7 +44,17 @@ class TicketState extends App.ControllerSubContent
@setDefaultState('default_follow_up', id)
available: (object) ->
object.active and not object.default_follow_up and object.state_type.name isnt 'merged'
}
},
{
name: 'set_default_close'
display: __('Set default for closed tickets')
icon: 'reload'
class: 'set_default_close'
callback: (id) =>
@setDefaultState('default_close', id)
available: (object) ->
object.active and not object.default_close and object.state_type.name isnt 'merged'
},
]
}
container: @el.closest('.content')
@@ -64,13 +74,16 @@ class TicketState extends App.ControllerSubContent
currentItem = App.TicketState.findByAttribute(type, true)
selectedItem = App.TicketState.find(id)
return if currentItem.id is selectedItem.id
return if not selectedItem
return if currentItem?.id is selectedItem.id
selectedItem.updateAttribute(type, true)
if type == 'default_create'
currentItem?.refresh(default_create: false)
else if type == 'default_follow_up'
currentItem?.refresh(default_follow_up: false)
else if type == 'default_close'
currentItem?.refresh(default_close: false)
else
console.error('Unknown default state type', type)

View File

@@ -853,6 +853,8 @@ class App.TicketZoom extends App.Controller
if _.isEmpty(contentKeys)
delete articleDiff.type
delete articleDiff.internal
else
articleDiff.internal = currentParams.article.internal
{
ticket: @forRemoveMeta(App.Utils.formDiff(currentParams.ticket, currentStore.ticket))

View File

@@ -8,7 +8,6 @@ class App.TicketZoomChecklistModal extends App.ControllerModal
text: __('No, just close the ticket')
}]
head: __('Incomplete Ticket Checklist')
small: true
events:
'submit form': 'submit'

View File

@@ -4,6 +4,13 @@ class App.TicketZoomSidebar extends App.ControllerObserver
customer_id: true
organization_id: true
constructor: ->
super
@controllerBind('ui::sidebar::toggleTab', (data) ->
App.Event.trigger('ui::ticket::sidebarToggleTab', { name: data.name })
)
release: =>
super
if @sidebarBackends

View File

@@ -57,6 +57,18 @@ class App.SidebarTicketSummary extends App.Controller
@loadSummarization()
)
@controllerBind('ui::ticket::sidebarToggleTab', (data) =>
return if not @parent?.activeState
return if @summarizeOnTicketShow()
return if data.name is @sidebarItem()?.name
# Reset activation flag when sidebar tab is switched to any other (#6131).
# Do this only:
# - For the currently active ticket
# - If the summary is not set to be generated on ticket show
@summaryActivated = false
)
isLoadSummaryNow: =>
if @summarizeOnTicketShow()
!!@parent?.activeState

View File

@@ -64,6 +64,7 @@ class User extends App.ControllerSubContent
display: __('View from user\'s perspective')
icon: 'switchView '
class: 'create js-switchTo'
available: (user) -> user.active
callback: (id) =>
@disconnectClient()
$('#app').hide().attr('style', 'display: none!important')

View File

@@ -149,6 +149,8 @@ class App.Sidebar extends App.Controller
if itemLocal && itemLocal.name && itemLocal.name is @currentTab && item.shown
item.shown()
App.Event.trigger('ui::sidebar::toggleTab', { name })
# show sidebar if not shown
@showSidebar()

View File

@@ -182,7 +182,7 @@ class App.TicketBatch extends App.Controller
groupId = @hoveredBatchEntry.attr('data-group-id')
items = @parentEl.find('[name="bulk"]:checked')
@hoveredBatchEntry.removeAttr('style')
@cleanUpDrag(true)
@cleanUpDragToLoading()
@performBatchAction items, action, id, groupId
@batchDragger.velocity
@@ -210,6 +210,36 @@ class App.TicketBatch extends App.Controller
@el.find('[name="bulk"]:checked').prop('checked', false)
@el.find('[name="bulk_all"]').prop('checked', false)
cleanUpDragToLoading: ->
$(document).off 'mousemove.batchoverlay'
@batchOverlayShown = false
@hideBatchCircles() if @batchCirclesShown
@hideBatchAssign() if @batchAssignShown
@hideBatchMacro() if @batchMacroShown
@hideBatchAssignGroup() if @batchAssignGroupShown
@el.removeClass('u-no-userselect')
$('.batch-dragger').remove()
@hoveredBatchEntry = null
if @grabbedItemWasntChecked
@grabbedItem.find('[name="bulk"]').prop('checked', false)
# uncheck all selected items
@parentEl.find('[name="bulk"]:checked').prop('checked', false)
@parentEl.find('[name="bulk_all"]').prop('checked', false)
# switch overlay to loading state, keeping backdrop visible
@el.addClass('is-loading')
hideBatchLoading: ->
@batchOverlayBackdrop.velocity { opacity: [0, 1] },
duration: 300
queue: false
complete: =>
@el.removeClass('is-loading is-visible')
$('html').css('overflow', '')
@batchCirclesShown = false
@batchAssignShown = false
@batchMacroShown = false
@batchAssignGroupShown = false
alignDraggedItems: (dir) ->
@draggedItems.velocity
properties:
@@ -255,7 +285,11 @@ class App.TicketBatch extends App.Controller
attributes:
group_id: id
@parent.ajax_mass(path, data, @batchSuccess)
wrappedSuccess = =>
@hideBatchLoading()
@batchSuccess?()
@parent.ajax_mass(path, data, wrappedSuccess, { skipLoading: true, onError: => @hideBatchLoading() })
showBatchOverlay: ->
@el.addClass('is-visible')

View File

@@ -713,7 +713,17 @@
if ( (!text_plain || text_plain == '') && !this.$element.find('img').get(0) ) {
return text_plain
}
return this.$element.html().trim()
return this.htmlWithoutResizeShim()
}
// Drop the resize handles and wrapper div that enableObjectResizingShim adds
// around a selected image (#6227): if these helper elements get saved, the
// sanitizer buries the image in whitespace-only divs and removes it.
Plugin.prototype.htmlWithoutResizeShim = function() {
var $clone = this.$element.clone()
$clone.find('div.enableObjectResizingShim-handle').remove()
$clone.find('img.objectResizingEditorActive').not('.enableObjectResizingShim-clone').unwrap().removeClass('objectResizingEditorActive')
return $clone.html().trim()
}
// log method

View File

@@ -135,7 +135,7 @@
this.resizeDir = this.resizeCorner == 0 || this.resizeCorner == 3 ? -1 : 1
this.startX = event.pageX
this.startWidth = this.$element.width()
this.$clone = this.$element.clone().css({width: '', height: '', 'max-width': maxWidth}).addClass('enableObjectResizingShim-clone enableObjectResizingShim-clone--'+ this.resizeCorner)
this.$clone = this.$element.clone().css({width: '', height: '', 'max-height': '', 'max-width': maxWidth}).addClass('enableObjectResizingShim-clone enableObjectResizingShim-clone--'+ this.resizeCorner)
this.$element.after(this.$clone)
this.isResizing = true
$(document).on('mousemove.enableObjectResizing', this.resize.bind(this))
@@ -170,7 +170,8 @@
this.$element.css({
width: width,
height: height,
'max-width': '100%'
'max-width': '100%',
'max-height': ''
})
this.$clone.remove()

View File

@@ -5,8 +5,8 @@ InstanceMethods =
ajax_mass_macro: (data, success) ->
@ajax_mass('macro', data, success)
ajax_mass: (path, data, success) ->
@startLoading()
ajax_mass: (path, data, success, options = {}) ->
@startLoading() if !options.skipLoading
@ajax(
id: 'bulk_update'
@@ -26,6 +26,7 @@ InstanceMethods =
error: (xhr, status, error) =>
@stopLoading()
options.onError?()
return if xhr.status != 422

View File

@@ -4,8 +4,8 @@ class App.PublicLink extends App.Model
@url: @apiPath + '/public_links'
@configure_attributes = [
{ name: 'link', display: __('Link'), tag: 'input', type: 'text', limit: 500, 'null': false, placeholder: 'https://link' },
{ name: 'title', display: __('Title'), tag: 'input', type: 'text', limit: 200, 'null': false },
{ name: 'description', display: __('Description (shown as title tag for screen readers)'), tag: 'input', type: 'text', limit: 200, 'null': true },
{ name: 'title', display: __('Title'), tag: 'input', type: 'text', limit: 200, 'null': false, translate: true },
{ name: 'description', display: __('Description (shown as title tag for screen readers)'), tag: 'input', type: 'text', limit: 200, 'null': true, translate: true },
{ name: 'screen', display: __('Context'), tag: 'multiselect', options: { login: __('Login Screen'), signup: __('Signup Screen'), password_reset: __('Forgot Password Screen') }, default: ['login'], 'null': false, multiple: true, translate: true },
{ name: 'new_tab', display: __('Open in new tab'), tag: 'select', options: { true: __('yes'), false: __('no'), }, default: true, 'null': false, translate: true },
{ name: 'prio', display: __('Prio'), readonly: 1 },

View File

@@ -11,6 +11,9 @@ class App.Setting extends App.Model
@set: (name, value, options = {}) ->
setting = App.Setting.findByAttribute('name', name)
throw "No such setting '#{name}' found!" if !setting
setting = App.Setting.findNative(setting.id)
previousState = $.extend(true, {}, setting.state_current)
setting.state_current.value = value
if !options.done
options.done = ->
@@ -24,8 +27,17 @@ class App.Setting extends App.Model
if options.doneLocal
options.doneLocal(@)
if !options.fail
options.fail = (settings, details) ->
failCallback = options.fail if options.fail
options.fail = (settings, details) ->
# Restore previous state on failure, both in the record and in the frontend config.
# https://github.com/zammad/zammad/issues/6273
setting.state_current = previousState
if setting.frontend
App.Config.set(name, setting.state_current.value)
if failCallback
failCallback.call(@, settings, details)
else
App.Event.trigger 'notify', {
type: 'error'
msg: details?.error_human || details?.error || __('The setting could not be updated.')
@@ -33,6 +45,7 @@ class App.Setting extends App.Model
}
if options.failLocal
options.failLocal(@)
if setting.frontend
App.Config.set(name, value)
setting.save(options)

View File

@@ -1,5 +1,5 @@
class App.TicketState extends App.Model
@configure 'TicketState', 'name', 'state_type_id', 'next_state_id', 'default_create', 'default_follow_up', 'ignore_escalation', 'note', 'active', 'updated_at'
@configure 'TicketState', 'name', 'state_type_id', 'next_state_id', 'default_create', 'default_follow_up', 'default_close', 'ignore_escalation', 'note', 'active', 'updated_at'
@extend Spine.Model.Ajax
@url: @apiPath + '/ticket_states'
@configure_attributes = [
@@ -29,7 +29,7 @@ A ticket's state is used to categorize and manage the lifecycle of a ticket or c
active: (object) ->
object.default_create
attribute: 'name'
class: 'primary'
class: 'new'
},
{
display: __('Default for follow-ups')
@@ -37,7 +37,14 @@ A ticket's state is used to categorize and manage the lifecycle of a ticket or c
object.default_follow_up
attribute: 'name'
class: 'primary'
}
},
{
display: __('Default for closed tickets')
active: (object) ->
object.default_close
attribute: 'name'
class: 'closed'
},
]
@byCategory: (category) ->

View File

@@ -3,6 +3,15 @@ class App.User extends App.Model
@extend Spine.Model.Ajax
@url: @apiPath + '/users'
# saving a record copies all attributes into the local store, but server responses
# never include the password, so it would stay there for the rest of the session -
# prefilled in the edit dialog and re-submitted as a password change on every
# subsequent update of the user
@bind 'save', (record) ->
storeRecord = App.User.irecords[record?.id]
return if !storeRecord
delete storeRecord.password
# @hasMany 'roles', 'App.Role'
@configure_attributes = [
{ name: 'login', display: __('Login'), tag: 'input', type: 'text', limit: 100, null: false, autocapitalize: false, signup: false, quick: false, no_perform_changes: true },

View File

@@ -1,12 +1,11 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('Microsoft 365 Graph Email') %> <small><%- @T('Accounts') %></small></h1>
</div>
<div class="page-header-title"></div>
<div class="page-header-meta">
<a class="btn js-configApp"><%- @T('Configure App') %></a>
<a class="btn btn--success js-new"><%- @T('Add Account') %></a>
<a class="btn btn--success js-admin-consent"><%- @T('Request Admin Consent') %></a>
<% if @has_admin_consent: %>
<a class="btn js-admin-consent"><%- @T('Request Admin Consent') %></a>
<% end %>
</div>
</div>
@@ -39,6 +38,11 @@
<% end %>
<% for channel in @channels: %>
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
<% if channel.needs_reauthentication: %>
<div class="alert alert--warning" style="flex-basis: 100%;">
<%- @T('The app configuration has changed since this channel was authenticated. Please click "Reauthenticate" to keep it working.') %>
</div>
<% end %>
<div class="action-flow" style="width: 100%;">
<div class="action-block action-block--flex">
<div class="horizontal">
@@ -56,7 +60,7 @@
<hr>
<% if channel.options?.inbound?.options: %>
<% if @has_mailbox_info and channel.options?.inbound?.options: %>
<% if channel.options.inbound.options.shared_mailbox: %>
<h3><%- @T('Shared Mailbox') %></h3>
<p><%= channel.options.inbound.options.shared_mailbox %></p>
@@ -124,4 +128,5 @@
</div>
</div>
<% end %>
<a class="btn btn--success js-new"><%- @T('Add Account') %></a>
</div>

View File

@@ -5,7 +5,7 @@
<a class="btn js-config-app"><%- @T('Configure App') %></a>
<% if _.isEmpty(@oauth): %>
<a class="btn btn--success js-new-app"><%- @T('Add Account') %></a>
<a class="btn btn--success js-admin-consent"><%- @T('Request Admin Consent') %></a>
<a class="btn js-admin-consent"><%- @T('Request Admin Consent') %></a>
<% end %>
</div>
</div>

View File

@@ -26,7 +26,7 @@
<tbody>
<% for option in @optionsSelected: %>
<tr data-id="<%= option.value %>">
<td><%- option.label %></td>
<td><%= option.label %></td>
<% if @showDescription: %><td><textarea class="form-control js-description"><%- @T(option.description) %></textarea></td><% end %>
<td class="<% if !@showDescription: %>settings-list-middle<% end %>">
<a class="btn btn--text btn--primary js-remove">

View File

@@ -7,7 +7,7 @@
<div class="alert alert--danger">
<p><%- @T('Writing assistant could not generate text. Please try again or contact your administrator.') %></p>
<% if @error_message: %>
<p><%- @T('API server error: %s', @T(@error_message)) %></p>
<p><%- @T('API server error: %s', @Tp(@error_message)) %></p>
<% end %>
</div>

View File

@@ -5,7 +5,7 @@
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
<span class="label-text"><%- @Ti(role.displayName()) %></span>
<% if role.note: %><span class="help-text"><%- @T(role.note) %></span><% end %>
<% if role.note: %><span class="help-text"><%- @T(App.Utils.html2text(role.note)) %></span><% end %>
</label>
<% end %>
</div>

View File

@@ -1,7 +1,5 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('Google Email') %> <small><%- @T('Accounts') %></small></h1>
</div>
<div class="page-header-title"></div>
</div>
<div class="page-content">

View File

@@ -1,116 +0,0 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('Google Email') %> <small><%- @T('Accounts') %></small></h1>
</div>
<div class="page-header-meta">
<a class="btn js-configApp"><%- @T('Configure App') %></a>
<a class="btn btn--success js-new"><%- @T('Add Account') %></a>
</div>
</div>
<% if !_.isEmpty(@not_used_email_addresses): %>
<div class="action">
<div class="action-flow">
<div class="action-block">
<%- @T('Notice:') %> <%- @T('Unassigned email addresses, assign them to a channel or delete them.') %></h3>
<ul class="list">
<% for email_address in @not_used_email_addresses: %>
<li class="list-item" data-id="<%= email_address.id %>">
<div class="list-item-name">
<a href="#" class="js-emailAddressEdit"><%= email_address.name %> &lt;<%= email_address.email %>&gt;</a>
</div>
<div class="list-item-delete js-emailAddressDelete">
<%- @Icon('diagonal-cross') %>
</div>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>
<div class="page-content">
<% if _.isEmpty(@channels): %>
<table class="table table--placeholder">
<thead><tr><th><%- @T('No Entries') %>
</table>
<% end %>
<% for channel in @channels: %>
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
<div class="action-flow" style="width: 100%;">
<div class="action-block action-block--flex">
<div class="horizontal">
<h3><%- @Icon('status', channel.status_in + " inline") %> <%- @T('Inbound') %></h3>
<% if channel.preferences.editable isnt false: %>
<div class="js-editInbound btn btn--text space-left"><%- @T('Edit') %></div>
<% end %>
</div>
<% if !_.isEmpty(channel.last_log_in): %>
<div class="alert alert--danger">
<%= channel.last_log_in %>
</div>
<% end %>
<hr>
<h3><%- @T('Destination Group') %></h3>
<a href="#" class="js-channelGroupChange <% if channel.group.active is false: %>alert alert--danger<% end %>">
<% if channel.group.active is false: %>
<%- @T('%s is inactive, please select an active one.', @P(channel.group, 'name')) %>
<% else: %>
<%- @P(channel.group, 'name') %>
<% end %>
</a>
</div>
<div class="action-block action-block--flex">
<div class="horizontal">
<h3><%- @Icon('status', channel.status_out + " inline") %> <%- @T('Outbound') %></h3>
</div>
<% if !_.isEmpty(channel.last_log_out): %>
<div class="alert alert--danger">
<%= channel.last_log_out %>
</div>
<% end %>
<hr>
<h3><%- @T('Email Address') %></h3>
<ul class="list">
<% if !_.isEmpty(channel.email_addresses): %>
<% for email_address in channel.email_addresses: %>
<li class="list-item" data-id="<%= email_address.id %>">
<div class="list-item-name"><%= email_address.email %></div>
<div class="btn btn--text js-emailAddressEdit space-left space-right"><%- @T('Edit') %></div>
<% if channel.email_addresses.length > 1: %>
<div class="list-item-delete js-emailAddressDelete">
<%- @Icon('diagonal-cross') %>
</div>
<% end %>
<% end %>
<% else: %>
<li class="list-item"><%- @T('none') %>
<% end %>
</ul>
<a class="text-muted js-emailAddressNew" href="#">+ <%- @T('Add') %></a>
</div>
</div>
<div class="action-controls">
<% if channel.options.backup_imap_classic: %>
<div class="btn js-rollbackMigration"><%- @T('Rollback migration') %></div>
<% end %>
<% 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>
<div class="btn btn--secondary js-reauthenticate"><%- @T('Reauthenticate') %></div>
</div>
</div>
<% end %>
</div>

View File

@@ -116,7 +116,7 @@
<% end %>
<% for link, link_index in @public_links: %>
<% if link_index > 0 || show_user_create_account: %> | <% end %>
<a href="<%= link.link %>" title="<% if link.description: %><%= link.description %><% else: %><%= link.title %><% end %>"<% if link.new_tab: %> target="_blank"<% end %>><%= link.title %></a>
<a href="<%= link.link %>"<% if link.description: %> title="<%- @T(link.description) %>"<% end %><% if link.new_tab: %> rel="noopener noreferrer" target="_blank"<% end %>><%- @T(link.title) %></a>
<% end %>
</p>
<% end %>

View File

@@ -1,7 +1,5 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('Microsoft 365 IMAP Email') %> <small><%- @T('Accounts') %></small></h1>
</div>
<div class="page-header-title"></div>
</div>
<div class="page-content">

View File

@@ -1,117 +0,0 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('Microsoft 365 IMAP Email') %> <small><%- @T('Accounts') %></small></h1>
</div>
<div class="page-header-meta">
<a class="btn js-configApp"><%- @T('Configure App') %></a>
<a class="btn btn--success js-new"><%- @T('Add Account') %></a>
<a class="btn btn--success js-admin-consent"><%- @T('Request Admin Consent') %></a>
</div>
</div>
<% if !_.isEmpty(@not_used_email_addresses): %>
<div class="action">
<div class="action-flow">
<div class="action-block">
<%- @T('Notice:') %> <%- @T('Unassigned email addresses, assign them to a channel or delete them.') %></h3>
<ul class="list">
<% for email_address in @not_used_email_addresses: %>
<li class="list-item" data-id="<%= email_address.id %>">
<div class="list-item-name">
<a href="#" class="js-emailAddressEdit"><%= email_address.name %> &lt;<%= email_address.email %>&gt;</a>
</div>
<div class="list-item-delete js-emailAddressDelete">
<%- @Icon('diagonal-cross') %>
</div>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>
<div class="page-content">
<% if _.isEmpty(@channels): %>
<table class="table table--placeholder">
<thead><tr><th><%- @T('No Entries') %>
</table>
<% end %>
<% for channel in @channels: %>
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
<div class="action-flow" style="width: 100%;">
<div class="action-block action-block--flex">
<div class="horizontal">
<h3><%- @Icon('status', channel.status_in + " inline") %> <%- @T('Inbound') %></h3>
<% if channel.preferences.editable isnt false: %>
<div class="js-editInbound btn btn--text space-left"><%- @T('Edit') %></div>
<% end %>
</div>
<% if !_.isEmpty(channel.last_log_in): %>
<div class="alert alert--danger">
<%= channel.last_log_in %>
</div>
<% end %>
<hr>
<h3><%- @T('Destination Group') %></h3>
<a href="#" class="js-channelGroupChange <% if channel.group.active is false: %>alert alert--danger<% end %>">
<% if channel.group.active is false: %>
<%- @T('%s is inactive, please select an active one.', @P(channel.group, 'name')) %>
<% else: %>
<%- @P(channel.group, 'name') %>
<% end %>
</a>
</div>
<div class="action-block action-block--flex">
<div class="horizontal">
<h3><%- @Icon('status', channel.status_out + " inline") %> <%- @T('Outbound') %></h3>
</div>
<% if !_.isEmpty(channel.last_log_out): %>
<div class="alert alert--danger">
<%= channel.last_log_out %>
</div>
<% end %>
<hr>
<h3><%- @T('Email Address') %></h3>
<ul class="list">
<% if !_.isEmpty(channel.email_addresses): %>
<% for email_address in channel.email_addresses: %>
<li class="list-item" data-id="<%= email_address.id %>">
<div class="list-item-name"><%= email_address.email %></div>
<div class="btn btn--text js-emailAddressEdit space-left space-right"><%- @T('Edit') %></div>
<% if channel.email_addresses.length > 1: %>
<div class="list-item-delete js-emailAddressDelete">
<%- @Icon('diagonal-cross') %>
</div>
<% end %>
<% end %>
<% else: %>
<li class="list-item"><%- @T('none') %>
<% end %>
</ul>
<a class="text-muted js-emailAddressNew" href="#">+ <%- @T('Add') %></a>
</div>
</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 %>
<div class="btn btn--secondary js-reauthenticate"><%- @T('Reauthenticate') %></div>
</div>
</div>
<% end %>
</div>

View File

@@ -1,8 +1,6 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('Microsoft 365 Graph Email') %> <small><%- @T('Accounts') %></small></h1>
</div>
<div class="page-header-title"></div>
</div>
<div class="page-content">
@@ -11,4 +9,3 @@
<div class="btn btn--success js-configApp"><%- @T('Connect Microsoft 365 App') %></div>
</div>
</div>

View File

@@ -14,7 +14,7 @@
<p>
<% for link, link_index in @public_links: %>
<% if link_index > 0: %> | <% end %>
<a href="<%= link.link %>" title="<% if link.description: %><%= link.description %><% else: %><%= link.title %><% end %>"<% if link.new_tab: %> target="_blank"<% end %>><%= link.title %></a>
<a href="<%= link.link %>"<% if link.description: %> title="<%- @T(link.description) %>"<% end %><% if link.new_tab: %> rel="noopener noreferrer" target="_blank"<% end %>><%- @T(link.title) %></a>
<% end %>
</p>
<% end %>

View File

@@ -14,7 +14,7 @@
<p>
<% for link, link_index in @public_links: %>
<% if link_index > 0: %> | <% end %>
<a href="<%= link.link %>" title="<% if link.description: %><%= link.description %><% else: %><%= link.title %><% end %>"<% if link.new_tab: %> target="_blank"<% end %>><%= link.title %></a>
<a href="<%= link.link %>"<% if link.description: %> title="<%- @T(link.description) %>"<% end %><% if link.new_tab: %> rel="noopener noreferrer" target="_blank"<% end %>><%- @T(link.title) %></a>
<% end %>
</p>
<% end %>

View File

@@ -9,7 +9,7 @@
<p>
<% for link, link_index in @public_links: %>
<% if link_index > 0: %> | <% end %>
<a href="<%= link.link %>" title="<% if link.description: %><%= link.description %><% else: %><%= link.title %><% end %>"<% if link.new_tab: %> target="_blank"<% end %>><%= link.title %></a>
<a href="<%= link.link %>"<% if link.description: %> title="<%- @T(link.description) %>"<% end %><% if link.new_tab: %> rel="noopener noreferrer" target="_blank"<% end %>><%- @T(link.title) %></a>
<% end %>
</p>
<% end %>

View File

@@ -12,7 +12,7 @@
<p>
<% for link, link_index in @public_links: %>
<% if link_index > 0: %> | <% end %>
<a href="<%= link.link %>" title="<% if link.description: %><%= link.description %><% else: %><%= link.title %><% end %>"<% if link.new_tab: %> target="_blank"<% end %>><%= link.title %></a>
<a href="<%= link.link %>"<% if link.description: %> title="<%- @T(link.description) %>"<% end %><% if link.new_tab: %> rel="noopener noreferrer" target="_blank"<% end %>><%- @T(link.title) %></a>
<% end %>
</p>
<% end %>

View File

@@ -19,7 +19,7 @@
<% for device in @devices: %>
<tr>
<td><%= device.name %></td>
<td><%= device.location %></td>
<td><%= if !device.location or device.location is 'unknown' then @T('Unknown') else device.location %></td>
<td><%- @humanTime(device.updated_at) %></td>
<td class="settings-list-controls">
<div class="settings-list-control" href="#" data-device-id="<%- device.id %>" data-type="delete" title="<%- @Ti('Delete') %>"<% if device.current: %>disabled<% end %>><%- @Icon('trash') %></div>

View File

@@ -14,12 +14,12 @@
rake zammad:store:move_files SOURCE_STORAGE TARGET_STORAGE
</code>
<p class="help-text"><%- @T('Examples:') %></p>
<p class="help-text"><%- @T('Move all from "%s" to "%s":', @T('Filesystem'), @T('Database')) %></p>
<p class="help-text"><%- @T('Move all from "%s" to "%s":', @Tp('Filesystem'), @Tp('Database')) %></p>
</p>
<code>
rake zammad:store:move_files File DB
</code>
<p class="help-text"><%- @T('Move all from "%s" to "%s":', @T('Database'), @T('Simple Storage (S3)')) %></p>
<p class="help-text"><%- @T('Move all from "%s" to "%s":', @Tp('Database'), @Tp('Simple Storage (S3)')) %></p>
</p>
<code>
rake zammad:store:move_files DB S3

View File

@@ -13,7 +13,7 @@
<p>
<% for link, link_index in @public_links: %>
<% if link_index > 0: %> | <% end %>
<a href="<%= link.link %>" title="<% if link.description: %><%= link.description %><% else: %><%= link.title %><% end %>"<% if link.new_tab: %> target="_blank"<% end %>><%= link.title %></a>
<a href="<%= link.link %>"<% if link.description: %> title="<%- @T(link.description) %>"<% end %><% if link.new_tab: %> rel="noopener noreferrer" target="_blank"<% end %>><%- @T(link.title) %></a>
<% end %>
</p>
<% end %>

View File

@@ -17,4 +17,8 @@
<div class="batch-overlay-assign batch-overlay-assign-group batch-overlay-box js-batch-assign-group">
<div class="batch-overlay-assign-group-name js-batch-assign-group-name"></div>
<div class="batch-overlay-box-inner js-batch-assign-group-inner"></div>
</div>
</div>
<div class="batch-overlay-loading">
<div class="small loading icon"></div>
<div class="batch-overlay-loading-label"><%- @T('Executing bulk action…') %></div>
</div>

View File

@@ -4,7 +4,7 @@
<div class="alert alert--danger">
<%- @T('The summary could not be generated. Please try again later or contact your administrator.') %>
<% if @data?.error_message: %>
<p><%- @T('API server error: %s', @T(@data.error_message)) %></p>
<p><%- @T('API server error: %s', @Tp(@data.error_message)) %></p>
<% end %>
</div>
<div class="retry-container">

View File

@@ -1,6 +1,3 @@
@import url('./svg-dimensions.css');
@import url('./font.css');
$highlight-color: hsl(205, 90%, 60%);
$color: hsl(207, 7%, 29%);
$light-color: hsl(206, 8%, 50%);

View File

@@ -1510,6 +1510,15 @@ pre code {
background: var(--ai-assist-pink);
color: var(--text-active);
}
&.badge--new {
color: hsl(45, 98%, 17%);
background: var(--ok-color);
}
&.badge--closed {
background: var(--supergood-color);
}
}
.key-value {
@@ -2787,6 +2796,7 @@ input.has-error {
}
.help-inline:not(:empty) {
display: block;
color: var(--danger-color);
padding: 2px;
font-size: 13px;
@@ -12619,7 +12629,8 @@ output {
color: var(--text-inverted);
padding: 0 14px 11px;
position: absolute;
min-width: 0;
min-width: 0; /* for unsupported browsers that don't support max-content below */
min-width: max-content;
cursor: default;
&.datepicker-rtl {
@@ -12651,6 +12662,7 @@ output {
th,
td {
border: none;
background: transparent;
}
.next,
@@ -13934,6 +13946,35 @@ output {
display: block;
}
&.is-loading {
cursor: default;
.batch-overlay-circle,
.batch-overlay-cancel,
.batch-overlay-box {
visibility: hidden !important;
}
}
&-loading {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
align-items: center;
.is-loading & {
display: flex;
}
}
&-loading-label {
@include bidi-style(margin-left, 10px, margin-right, 0);
margin-top: 1px;
}
&-backdrop {
@extend .fit;

View File

@@ -11,9 +11,10 @@ class GraphqlChannel < ApplicationCable::Channel
operation_name = data['operationName']
# context must be kept in sync with GraphqlController!
# Channels use session auth only (no token auth), so we set the token to nil here.
context = {
sid: sid,
current_user: current_user,
current_user: current_user && UserContext.new(current_user, nil),
current_user_id: current_user&.id,
# :channel is required for ActionCableSubscriptions and MUST NOT be used otherwise.
channel: self,

View File

@@ -54,9 +54,22 @@ class AI::Analytics::DownloadsController < ApplicationController
scope = scope.where(filter => filters[filter]) if filters[filter].present?
end
scope = scope.where(created_at: (filters[:created_after])..) if filters[:created_after].present?
scope = scope.where(created_at: ..(filters[:created_before])) if filters[:created_before].present?
created_after = parse_date(filters[:created_after])
created_before = parse_date(filters[:created_before])
scope = scope.where(created_at: (created_after)..) if created_after
scope = scope.where(created_at: ..(created_before)) if created_before
scope
end
# Ignore blank or unparseable date filters instead of letting them cast to
# NULL in the query, which would silently match no records.
def parse_date(value)
return if value.blank?
Time.zone.parse(value.to_s)
rescue ArgumentError
nil
end
end

View File

@@ -106,6 +106,7 @@ module ApplicationController::HandlesErrors
data[:error] = __('Authorization failed')
data[:error_human] = data[:error]
elsif e.instance_of?(Exceptions::InvalidAttribute)
data[:error_human] = data[:error]
data[:invalid_attribute] = { e.attribute => data[:error] }
elsif e.is_a?(Exceptions::UnprocessableContent)
data[:error_human] = data[:error]

View File

@@ -41,9 +41,8 @@ module ApplicationController::HasUser
return if request_header_from.blank? # require header
return @_user_on_behalf if @_user_on_behalf # return memoized user
return if !current_user_real # require session user
if !SessionsPolicy.new(current_user_real, Sessions).impersonate?
raise Exceptions::Forbidden, __("Current user has no permission to use 'From'/'X-On-Behalf-Of'!")
end
impersonate!
@_user_on_behalf = find_on_behalf_user request_header_from.to_s.downcase.strip
@@ -55,16 +54,31 @@ module ApplicationController::HasUser
@_user_on_behalf
end
def impersonate!
return if SessionsPolicy.new(current_user_real, Sessions).impersonate?
raise Exceptions::Forbidden, __("Current user has no permission to use 'From'/'X-On-Behalf-Of'!")
end
def current_user_set(user, auth_type = 'session')
session[:user_id] = user.id
@_auth_type = auth_type
@_current_user = user
@_auth_type = auth_type
@_current_user = user
@_user_on_behalf = nil
set_user
end
# Sets the current user into a named Thread location so that it can be accessed
# by models and observers
def set_user
# The order of setting current_user and current_token is important,
# because current_token is used in UserInfo.current_user_id= and it needs to be set before.
# If current_user is set before current_token, then UserInfo.current_user_id= will use the old token
# instead of the new one, which can cause issues with token-based authentication.
UserInfo.current_token = nil
UserInfo.current_token = current_user_on_behalf ? nil : @_token
UserInfo.current_user_id = current_user&.id || 1
end

View File

@@ -1,7 +1,7 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class AttachmentsController < ApplicationController
prepend_before_action :authorize!, only: %i[show destroy]
prepend_before_action :authorize!, only: %i[show destroy destroy_form]
prepend_before_action :authentication_check, except: %i[show destroy]
prepend_before_action :authentication_check_only, only: %i[show destroy]
@@ -29,16 +29,13 @@ class AttachmentsController < ApplicationController
end
end
headers_store = {
'Content-Type' => content_type
}
store = Store.create!(
object: 'UploadCache',
o_id: params[:form_id],
data: file.read,
filename: file.original_filename,
preferences: headers_store
store = UploadCache.new(params[:form_id]).add(
filename: file.original_filename,
data: file.read,
preferences: {
'Content-Type' => content_type,
},
created_by_id: current_user.id,
)
render json: {
@@ -61,10 +58,7 @@ class AttachmentsController < ApplicationController
end
def destroy_form
Store.remove(
object: 'UploadCache',
o_id: params[:form_id],
)
UploadCache.new(params[:form_id]).destroy
render json: {
success: true,

View File

@@ -44,6 +44,10 @@ module CreatesTicketArticles # rubocop:disable Metrics/ModuleLength
clean_params[:internal] = false
end
if Ticket::Article::Type.lookup(id: clean_params[:type_id])&.name == 'email' && ticket.group.email_address.blank?
raise Exceptions::UnprocessableContent, __('This group has no email address configured for outgoing communication.')
end
article = Ticket::Article.new(clean_params)
article.ticket_id = ticket.id
article.check_mentions_raises_error = true

View File

@@ -3,6 +3,8 @@
module Integration::ImportJobBase
extend ActiveSupport::Concern
IMPORT_JOB_SENSITIVE_FIELDS = [].freeze
def job_try_index
job_index(
dry_run: true,
@@ -76,4 +78,9 @@ module Integration::ImportJobBase
end
end
def sensitive_attributes(_input, object)
return self.class::IMPORT_JOB_SENSITIVE_FIELDS if object.is_a?(ImportJob)
super
end
end

View File

@@ -36,6 +36,7 @@ class ExternalCredentialsController < ApplicationController
provider = params[:provider].downcase
attributes = ExternalCredential.request_account_to_link(provider, params)
session[:request_token] = attributes[:request_token]
session[:code_verifier] = attributes[:code_verifier]
session[:channel_id] = params[:channel_id]
session[:shared_mailbox] = params[:shared_mailbox]
redirect_to attributes[:authorize_url], allow_other_host: true
@@ -46,16 +47,18 @@ class ExternalCredentialsController < ApplicationController
channel = ExternalCredential.link_account(provider, session[:request_token], link_params)
return redirect_to(channel), allow_other_host: true if channel.instance_of?(String)
session[:request_token] = nil
session[:channel_id] = nil
session[:shared_mailbox] = nil
redirect_to app_url(provider, channel.id), allow_other_host: true
ensure
session[:request_token] = nil
session[:code_verifier] = nil
session[:channel_id] = nil
session[:shared_mailbox] = nil
end
private
def link_params
params.permit!.to_h.merge(channel_id: session[:channel_id], shared_mailbox: session[:shared_mailbox])
params.permit!.to_h.merge(channel_id: session[:channel_id], shared_mailbox: session[:shared_mailbox], code_verifier: session[:code_verifier])
end
def callback_url(provider)

View File

@@ -43,7 +43,7 @@ class ExternalDataSourceController < ApplicationController
next if !param_value
memo[model.name.downcase.to_sym] = model.find_by(id: param_value)
memo[model.name.downcase.to_sym] = authorized_record(model, param_value)
end
result[:user] ||= current_user
@@ -54,14 +54,23 @@ class ExternalDataSourceController < ApplicationController
result
end
def authorized_record(model, id)
record = model.find_by(id:)
return if !record
return if !authorized?(record, :show?)
record
end
def inject_ticket(search_context, result)
return if result[:ticket]
return if !search_context['customer_id']
customer = ::User.find_by(id: search_context['customer_id'])
customer = authorized_record(::User, search_context['customer_id'])
return if !customer
result[:ticket] = ::Ticket.new(customer: customer)
result[:ticket] = ::Ticket.new(customer:)
end
end

View File

@@ -50,9 +50,10 @@ class GraphqlController < ApplicationController
def context
# context must be kept in sync with GraphqlChannel!
token = current_user_on_behalf ? nil : @_token
{
sid: session.id,
current_user: current_user,
current_user: current_user && UserContext.new(current_user, token),
current_user_id: current_user&.id,
# :controller is used by login/logout mutations and MUST NOT be used otherwise.
controller: self,

View File

@@ -125,6 +125,8 @@ class ImportOtrsController < ApplicationController
end
def import_check
return if setup_done_response
Import::OTRS::Requester.list
issues = []
@@ -161,6 +163,11 @@ class ImportOtrsController < ApplicationController
def import_status
result = Import::OTRS.status_bg
if result[:message] == 'not running'
render json: result
return
end
if result[:result] == 'import_done'
Setting.reload

View File

@@ -5,6 +5,8 @@ class Integration::ExchangeController < ApplicationController
prepend_before_action :authenticate_and_authorize!
IMPORT_JOB_SENSITIVE_FIELDS = %w[payload.ews_config.password payload.ews_config.access_token].freeze
def index
assets = {}
external_credential_ids = []
@@ -67,7 +69,6 @@ class Integration::ExchangeController < ApplicationController
ews_attributes: params[:attributes].permit!.to_h,
ews_folder_ids: params[:folders],
ews_config: ews_config,
params: params,
}
end

View File

@@ -1,66 +1,16 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class Integration::GitHubController < ApplicationController
prepend_before_action :authenticate_and_authorize!
class Integration::GitHubController < Integration::IssueTrackerController
private
SENSITIVE_FIELDS = [:api_token].freeze
def verify
unmasked_params = unmask_sensitive_params(params, Setting.get('github_config'))
github = ::GitHub.new(endpoint: unmasked_params[:endpoint], api_token: unmasked_params[:api_token])
github.verify!
render json: {
result: 'ok',
}
rescue => e
logger.error e
render json: {
result: 'failed',
message: e.message,
}
def integration_type
'github'
end
def query
response = if params[:ticket_id]
Service::Ticket::ExternalReferences::IssueTracker::TicketList.execute(
type: 'github',
ticket: Ticket.find(params[:ticket_id]),
)
else
Service::Ticket::ExternalReferences::IssueTracker::FetchMetadata.execute(
type: 'github',
issue_links: params[:links],
)
end
render json: {
result: 'ok',
response: response,
}
rescue => e
logger.error e
render json: {
result: 'failed',
message: e.message,
}
end
def update
ticket = Ticket.find(params[:ticket_id])
ticket.with_lock do
authorize!(ticket, :show?)
ticket.preferences[:github] ||= {}
ticket.preferences[:github][:issue_links] = Array(params[:issue_links]).uniq
ticket.save!
end
render json: {
result: 'ok',
}
def integration_client
::GitHub.new(
endpoint: unmasked_params[:endpoint],
api_token: unmasked_params[:api_token],
)
end
end

View File

@@ -1,66 +1,17 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class Integration::GitLabController < ApplicationController
prepend_before_action :authenticate_and_authorize!
class Integration::GitLabController < Integration::IssueTrackerController
private
SENSITIVE_FIELDS = [:api_token].freeze
def verify
unmasked_params = unmask_sensitive_params(params, Setting.get('gitlab_config'))
gitlab = ::GitLab.new(endpoint: unmasked_params[:endpoint], api_token: unmasked_params[:api_token], verify_ssl: unmasked_params[:verify_ssl])
gitlab.verify!
render json: {
result: 'ok',
}
rescue => e
logger.error e
render json: {
result: 'failed',
message: e.message,
}
def integration_type
'gitlab'
end
def query
response = if params[:ticket_id]
Service::Ticket::ExternalReferences::IssueTracker::TicketList.execute(
type: 'gitlab',
ticket: Ticket.find(params[:ticket_id]),
)
else
Service::Ticket::ExternalReferences::IssueTracker::FetchMetadata.execute(
type: 'gitlab',
issue_links: params[:links],
)
end
render json: {
result: 'ok',
response: response,
}
rescue => e
logger.error e
render json: {
result: 'failed',
message: e.message,
}
end
def update
ticket = Ticket.find(params[:ticket_id])
ticket.with_lock do
authorize!(ticket, :show?)
ticket.preferences[:gitlab] ||= {}
ticket.preferences[:gitlab][:issue_links] = Array(params[:issue_links]).uniq
ticket.save!
end
render json: {
result: 'ok',
}
def integration_client
::GitLab.new(
endpoint: unmasked_params[:endpoint],
api_token: unmasked_params[:api_token],
verify_ssl: unmasked_params[:verify_ssl],
)
end
end

View File

@@ -0,0 +1,80 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class Integration::IssueTrackerController < ApplicationController
prepend_before_action :authenticate_and_authorize!
SENSITIVE_FIELDS = [:api_token].freeze
def verify
integration_client.verify!
render json: {
result: 'ok',
}
rescue => e
logger.error e
render json: {
result: 'failed',
message: e.message,
}
end
def query
response = if params[:ticket_id]
ticket = Ticket.find(params[:ticket_id])
authorize!(ticket, :agent_read_access?)
Service::Ticket::ExternalReferences::IssueTracker::TicketList.execute(
type: integration_type,
ticket: ticket,
)
else
Service::Ticket::ExternalReferences::IssueTracker::FetchMetadata.execute(
type: integration_type,
issue_links: params[:links],
)
end
render json: {
result: 'ok',
response: response,
}
rescue Pundit::NotAuthorizedError, ActiveRecord::RecordNotFound
raise
rescue => e
logger.error e
render json: {
result: 'failed',
message: e.message,
}
end
def update
ticket = Ticket.find(params[:ticket_id])
authorize!(ticket, :agent_read_access?)
ticket.with_lock do
ticket.preferences[integration_type.to_sym] ||= {}
ticket.preferences[integration_type.to_sym][:issue_links] = Array(params[:issue_links]).uniq
ticket.save!
end
render json: {
result: 'ok',
}
end
private
def integration_type
raise NotImplementedError
end
def integration_client
raise NotImplementedError
end
def unmasked_params
@unmasked_params ||= unmask_sensitive_params(params, Setting.get("#{integration_type}_config"))
end
end

View File

@@ -6,6 +6,7 @@ class Integration::LdapController < ApplicationController
prepend_before_action :authenticate_and_authorize!
SENSITIVE_FIELDS = [:bind_pw].freeze
IMPORT_JOB_SENSITIVE_FIELDS = %w[payload.ldap_config.bind_pw].freeze
EXCEPTIONS_SPECIAL_TREATMENT = {
'48, Inappropriate Authentication' => {}, # workaround for issue #1114
@@ -54,9 +55,9 @@ class Integration::LdapController < ApplicationController
private
def payload_dry_run
payload = unmask_sensitive_params(super, LdapSource.find_by(id: params[:ldap_source_id]))
payload = unmask_sensitive_params(super, LdapSource.find_by(id: params[:ldap_source_id])&.preferences)
{
ldap_config: payload
ldap_config: payload.symbolize_keys
}
end
end

View File

@@ -2,22 +2,21 @@
class KnowledgeBase::Answer::AttachmentsController < ApplicationController
prepend_before_action :authenticate_and_authorize!
before_action :fetch_answer
def create
@answer.add_attachment params[:file]
answer.add_attachment params[:file]
render json: @answer.assets({})
render json: answer.assets({})
end
def destroy
@answer.remove_attachment params[:id]
answer.remove_attachment params[:id]
render json: @answer.assets({})
render json: answer.assets({})
end
def clone_to_form
new_attachments = @answer.clone_attachments('UploadCache', params[:form_id], only_attached_attachments: true)
new_attachments = answer.clone_attachments('UploadCache', params[:form_id], only_attached_attachments: true)
render json: {
attachments: new_attachments
@@ -26,7 +25,7 @@ class KnowledgeBase::Answer::AttachmentsController < ApplicationController
private
def fetch_answer
@answer = KnowledgeBase::Answer.find params[:answer_id]
def answer
@answer ||= KnowledgeBase::Answer.find params[:answer_id]
end
end

View File

@@ -2,6 +2,7 @@
class KnowledgeBase::Public::FeedsController < KnowledgeBase::Public::BaseController
before_action :ensure_response_format
before_action :set_feed_url
helper_method :build_original_url, :publishing_date, :updating_date
@@ -12,7 +13,7 @@ class KnowledgeBase::Public::FeedsController < KnowledgeBase::Public::BaseContro
.sorted_by_published
.limit(10)
@root_url = custom_path_if_needed(help_root_url, @knowledge_base, full: true)
@root_url = custom_path_if_needed(help_root_path, @knowledge_base, full: true)
render template: 'knowledge_base/feeds/feed'
end
@@ -25,7 +26,7 @@ class KnowledgeBase::Public::FeedsController < KnowledgeBase::Public::BaseContro
.sorted_by_published
.limit(10)
@root_url = custom_path_if_needed(help_category_url, @knowledge_base, full: true)
@root_url = custom_path_if_needed(help_category_path, @knowledge_base, full: true)
render template: 'knowledge_base/feeds/feed'
end
@@ -36,9 +37,15 @@ class KnowledgeBase::Public::FeedsController < KnowledgeBase::Public::BaseContro
request.format = :atom
end
# The feed's own URL, rewritten for a custom address, drives the self link and
# Atom id (see the feed template) so the internal /help mount point never leaks.
def set_feed_url
@feed_url = custom_path_if_needed(request.path, @knowledge_base, full: true)
end
def build_original_url(answer)
translation = answer.translations.first
url = help_answer_url(answer.category, translation, locale: translation.kb_locale.system_locale.locale)
url = help_answer_path(answer.category, translation, locale: translation.kb_locale.system_locale.locale)
custom_path_if_needed url, @knowledge_base, full: true
end

View File

@@ -99,10 +99,16 @@ class LongPollingController < ApplicationController
def client_id_verify
return if !params[:client_id]
sessions = Sessions.sessions
return if sessions.exclude?(params[:client_id].to_s)
cid = params[:client_id].to_s
return if !Sessions.valid_client_id?(cid)
params[:client_id].to_s
session_data = Sessions.get(cid)
return if !session_data
stored_user_id = session_data.dig(:user, 'id')
return if stored_user_id && current_user&.id != stored_user_id.to_i
cid
end
def log(data, client_id = '-')

View File

@@ -9,8 +9,9 @@ class ProxyController < ApplicationController
options = params
.permit(:proxy, :proxy_username, :proxy_password, :proxy_no)
.to_h
options[:open_timeout] = 12
options[:read_timeout] = 24
options[:open_timeout] = 12
options[:read_timeout] = 24
options[:total_timeout] = 24
begin
result = UserAgent.get(
url,

View File

@@ -12,7 +12,7 @@ class ReportsController < ApplicationController
return
end
profiles = Report::ProfilesPolicy::Scope.new(current_user, Report::Profile).resolve
profiles = Report::ProfilesPolicy::Scope.new(current_user, Report::Profile).resolve.sorted
if profiles.blank?
render json: {
error: __('There are currently no report profiles configured.'),
@@ -43,14 +43,13 @@ class ReportsController < ApplicationController
next if params['backends'][backend[:name]].blank?
result[backend[:name]] = backend[:adapter].aggs(
range_start: get_params[:start],
range_end: get_params[:stop],
interval: get_params[:range],
selector: backend[:condition],
params: backend[:params],
timezone: get_params[:timezone],
timezone_offset: get_params[:timezone_offset],
current_user: current_user
range_start: get_params[:start],
range_end: get_params[:stop],
interval: get_params[:range],
selector: backend[:condition],
params: backend[:params],
timezone: get_params[:timezone],
current_user: current_user
)
end
@@ -87,15 +86,14 @@ class ReportsController < ApplicationController
next if !backend[:adapter]
result = backend[:adapter].items(
range_start: get_params[:start],
range_end: get_params[:stop],
interval: get_params[:range],
selector: backend[:condition],
params: backend[:params],
sheet: params[:sheet],
timezone: get_params[:timezone],
timezone_offset: get_params[:timezone_offset],
current_user: current_user
range_start: get_params[:start],
range_end: get_params[:stop],
interval: get_params[:range],
selector: backend[:condition],
params: backend[:params],
sheet: params[:sheet],
timezone: get_params[:timezone],
current_user: current_user
)
result = { count: 0, ticket_ids: [] } if result.nil?
@@ -125,6 +123,8 @@ class ReportsController < ApplicationController
render json: result
end
private
def params_all
if !params[:profiles] && !params[:profile_id]
raise Exceptions::UnprocessableContent, __("Required parameter 'profile' is missing.")
@@ -151,48 +151,56 @@ class ReportsController < ApplicationController
metric = local_config[:metric][params[:metric].to_sym]
case params[:timeRange]
timezone = params[:timezone].presence || Setting.get('timezone_default')
start_at, stop_at, range = Time.use_zone(timezone) do
time_range(params[:timeRange], **params.permit(:year, :month, :day, :week).to_h.symbolize_keys)
end
{
profile: profile,
metric: metric,
config: local_config,
start: start_at,
stop: stop_at,
range: range,
timezone: timezone,
}
end
def time_range(time_range, year: nil, month: nil, day: nil, week: nil)
case time_range
when 'realtime'
start_at = 60.minutes.ago
stop_at = Time.zone.now
range = 'minute'
when 'day'
date = Date.parse("#{params[:year]}-#{params[:month]}-#{params[:day]}").to_s
start_at = Time.zone.parse("#{date}T00:00:00Z")
stop_at = Time.zone.parse("#{date}T23:59:59Z")
range = 'hour'
date = Date.parse("#{year}-#{month}-#{day}")
start_at = date.beginning_of_day
stop_at = date.end_of_day
range = 'hour'
when 'week'
start_week_at = Date.commercial(params[:year].to_i, params[:week].to_i)
stop_week_at = start_week_at.end_of_week
start_at = Time.zone.parse("#{start_week_at.year}-#{start_week_at.month}-#{start_week_at.day}T00:00:00Z")
stop_at = Time.zone.parse("#{stop_week_at.year}-#{stop_week_at.month}-#{stop_week_at.day}T23:59:59Z")
range = 'week'
date = Date.commercial(year.to_i, week.to_i)
start_at = date.beginning_of_day
stop_at = date.end_of_week.end_of_day
range = 'week'
when 'month'
start_at = Time.zone.parse("#{params[:year]}-#{params[:month]}-01T00:00:00Z")
stop_at = Time.zone.parse("#{params[:year]}-#{params[:month]}-#{start_at.end_of_month.day}T23:59:59Z")
range = 'day'
date = Date.parse("#{year}-#{month}-01")
start_at = date.beginning_of_day
stop_at = date.end_of_month.end_of_day
range = 'day'
else
start_at = Time.zone.parse("#{params[:year]}-01-01T00:00:00Z")
stop_at = Time.zone.parse("#{params[:year]}-12-31T23:59:59Z")
range = 'month'
end
params[:timezone] ||= Setting.get('timezone_default')
if params[:timeRange] != 'realtime'
offset = stop_at.in_time_zone(params[:timezone]).utc_offset
start_at -= offset
stop_at -= offset
date = Date.parse("#{year}-01-01")
start_at = date.beginning_of_day
stop_at = date.end_of_year.end_of_day
range = 'month'
end
{
profile: profile,
metric: metric,
config: local_config,
start: start_at,
stop: stop_at,
range: range,
timezone: params[:timezone],
timezone_offset: offset,
}
[start_at, stop_at, range]
end
end

View File

@@ -28,7 +28,7 @@ class SessionsController < ApplicationController
.json_hash(user)
.merge(
config: config_frontend,
after_auth: Auth::AfterAuth.run(user, session, options: { initial: true })
after_auth: Auth::AfterAuth.run(user, session, options: { initial: true }),
)
# return new session data
@@ -66,7 +66,7 @@ class SessionsController < ApplicationController
initiate_session_for(user, 'SSO')
redirect_to '/#'
redirect_after_omniauth('/#')
end
# "Delete" a login, aka "log the user out"
@@ -149,7 +149,7 @@ class SessionsController < ApplicationController
end
# redirect to app
redirect_to redirect_url
redirect_after_omniauth(redirect_url)
rescue Authorization::Provider::AccountError => e
forbidden(e)
end
@@ -178,6 +178,8 @@ class SessionsController < ApplicationController
return false
end
raise Exceptions::UnprocessableContent, __('User is inactive.') if !user.active
# remember original user
session[:switched_from_user_id] ||= current_user.id
@@ -266,15 +268,19 @@ class SessionsController < ApplicationController
end
auth = Auth.new(params[:username], params[:password], only_verify_password: true)
begin
auth.valid!
two_factor_method = auth.user.auth_two_factor.authentication_method_object(params[:method])
raise Auth::Error::AuthenticationFailed if !two_factor_method&.enabled? || !two_factor_method&.available?
rescue Auth::Error::AuthenticationFailed
raise Exceptions::UnprocessableContent, __('The username or password is incorrect.')
rescue => e
logger.error(e)
raise Exceptions::UnprocessableContent, __('The username or password is incorrect.')
end
two_factor_method = auth.user.auth_two_factor.authentication_method_object(params[:method])
raise Exceptions::UnprocessableContent, __('The two-factor authentication method is not enabled.') if !two_factor_method&.enabled? || !two_factor_method&.available?
render json: two_factor_method.initiate_authentication, status: :ok
end
@@ -374,4 +380,14 @@ class SessionsController < ApplicationController
def omniauth_redirect_path
request.env['omniauth.params']['redirect'] || ''
end
def redirect_after_omniauth(default_url)
return_to = session[:doorkeeper_return_to]
if return_to.is_a?(String) && return_to.match?(%r{\A/oauth/authorize(?:\z|[/?#])})
redirect_to session.delete(:doorkeeper_return_to)
else
redirect_to default_url
end
end
end

View File

@@ -27,6 +27,7 @@ class Ticket::SummarizeController < ApplicationController
# When AI analytics error ID is present, return this error message instead of enqueuing a new job.
if params[:ai_analytics_run_error_id].present?
ai_analytics_run_error = AI::Analytics::Run.find(params[:ai_analytics_run_error_id])
authorize!(ai_analytics_run_error, :show?)
render json: {
result: nil,

View File

@@ -153,6 +153,7 @@ class TicketArticlesController < ApplicationController
def ticket_attachment_upload_clone_by_article
article = Ticket::Article.find(params[:article_id])
authorize!(article.ticket, :show?)
authorize!(article, :show?)
render json: {
attachments: article_attachments_clone(article),
@@ -176,6 +177,8 @@ class TicketArticlesController < ApplicationController
authorize!(ticket, :show?)
end
authorize!(article, :show?)
list = article.attachments || []
access = false
list.each do |item|

View File

@@ -8,8 +8,13 @@ class UserDevicesController < ApplicationController
devices_full = []
devices.each do |device|
attributes = device.attributes
if device.location_details['city_name'].present?
attributes['location'] += ", #{device.location_details['city_name']}"
city_name = device.location_details['city_name']
if city_name.present?
attributes['location'] = if attributes['location'].blank? || attributes['location'] == 'unknown'
city_name
else
"#{attributes['location']}, #{city_name}"
end
end
attributes.delete('created_at')
attributes.delete('device_details')

View File

@@ -1,12 +1,14 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockUserCurrent } from '#tests/support/mock-userCurrent.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
import { useBetaUi } from '#desktop/components/BetaUi/composables/useBetaUi.ts'
import { useAppUsageStore } from '#desktop/stores/appUsage.ts'
const waitForConfirmationMock = vi.fn().mockImplementation(() => true)
@@ -34,6 +36,7 @@ vi.mock(
)
describe('useNewBetaUi', () => {
beforeEach(() => setActivePinia(createPinia()))
afterAll(() => vi.clearAllMocks())
describe('betaUiSwitchEnabled', () => {
@@ -88,6 +91,8 @@ describe('useNewBetaUi', () => {
describe('toggleBetaUiSwitch', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.doMock('#shared/utils/pwa.ts')
Object.defineProperty(window, 'location', {
value: {
@@ -98,28 +103,28 @@ describe('useNewBetaUi', () => {
})
})
it('sets switchValue and hasFeedbackConsent to undefined', () => {
it('sets switchValue and hasFeedbackConsent to null', () => {
const { hasFeedbackConsent, switchValue, toggleBetaUiSwitch } = useBetaUi()
expect(switchValue.value).not.toBe(undefined)
expect(hasFeedbackConsent.value).not.toBe(undefined)
expect(switchValue.value).not.toBe(null)
expect(hasFeedbackConsent.value).not.toBe(null)
toggleBetaUiSwitch()
expect(switchValue.value).toBe(undefined)
expect(hasFeedbackConsent.value).toBe(undefined)
expect(switchValue.value).toBe(null)
expect(hasFeedbackConsent.value).toBe(null)
})
it('supports skipping setting hasFeedbackConsent to undefined', () => {
it('supports skipping setting hasFeedbackConsent to null', () => {
const { hasFeedbackConsent, switchValue, toggleBetaUiSwitch } = useBetaUi()
expect(switchValue.value).not.toBe(undefined)
expect(hasFeedbackConsent.value).not.toBe(undefined)
expect(switchValue.value).not.toBe(null)
expect(hasFeedbackConsent.value).not.toBe(null)
toggleBetaUiSwitch('/', true)
expect(switchValue.value).toBe(undefined)
expect(hasFeedbackConsent.value).not.toBe(undefined)
expect(switchValue.value).toBe(null)
expect(hasFeedbackConsent.value).not.toBe(null)
})
it('redirects to root URL', () => {
@@ -132,6 +137,34 @@ describe('useNewBetaUi', () => {
expect(window.location.href).toBe('/')
})
it('does not re-trigger milestone dialog after switching back to old UI', async () => {
// Regression test: previously, resetMilestoneHistory() in clearSwitchAndRedirect
// used a separate useLocalStorage instance. VueUse synced instances via StorageEvent,
// which caused the store's milestoneHistory ref to reset to {all: false} while
// totalAppUsageTime remained above the milestone threshold - flipping
// shouldTriggerMilestoneDialog back to true immediately after the switch.
localStorage.setItem('app-usage-total-time', `${5 * 60 * 60 * 1000}`)
localStorage.setItem(
'app-usage-milestones-trigger-history',
JSON.stringify({ '1h': true, '5h': true, '20h': false }),
)
const appUsage = useAppUsageStore()
// Sanity: 5h usage, milestone already seen - dialog must not show.
expect(appUsage.currentMilestoneKey).toBe('5h')
expect(appUsage.shouldTriggerMilestoneDialog).toBe(false)
const { toggleBetaUiSwitch } = useBetaUi()
toggleBetaUiSwitch('/', true)
await flushPromises()
// Both counters must be reset and the dialog must not re-trigger.
expect(appUsage.totalAppUsageTime).toBe(0)
expect(appUsage.shouldTriggerMilestoneDialog).toBe(false)
})
it('clears usage states', async () => {
localStorage.setItem('app-usage-total-time', `${1 * 60 * 60 * 1000}`) // 1 hour
localStorage.setItem(
@@ -146,9 +179,7 @@ describe('useNewBetaUi', () => {
toggleBetaUiSwitch('/', true)
await waitFor(() =>
expect(localStorage.getItem('app-usage-total-time')).toBe(`${1 * 60 * 60 * 1000}`),
)
await waitFor(() => expect(localStorage.getItem('app-usage-total-time')).toBe('0'))
expect(localStorage.getItem('app-usage-milestones-trigger-history')).toBe(
JSON.stringify({
@@ -184,6 +215,8 @@ describe('useNewBetaUi', () => {
})
describe('toggleDismissBetaUiSwitch', () => {
beforeEach(() => setActivePinia(createPinia()))
it('toggles dismissValue', () => {
localStorage.setItem('beta-ui-switch-dismiss', 'false')

View File

@@ -1,46 +1,39 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, toRef } from 'vue'
import { useConfirmation } from '#shared/composables/useConfirmation.ts'
import { useApplicationStore } from '#shared/stores/application.ts'
import { useSessionStore } from '#shared/stores/session.ts'
import { useAppUsage } from '#desktop/composables/BetaUi/useAppUsage.ts'
import { useAppUsageStore } from '#desktop/stores/appUsage.ts'
import { EnumFeedbackDialog, useFeedbackDialog } from '../FeedbackDialog/useFeedbackDialog.ts'
import { useBetaUiFeedbackConsentState } from './useBetaUiFeedbackConsentState.ts'
export const initializeBetaUi = () => {
const user = toRef(useSessionStore(), 'user')
const config = toRef(useApplicationStore(), 'config')
const { setNeverAskAgainForTimedFeedback, resetMilestoneHistory } = useAppUsage()
const betaUiSwitchAvailable = computed(
() => config.value?.ui_desktop_beta_switch && user.value?.hasBetaUiSwitchAvailable,
)
const switchValue = useLocalStorage('beta-ui-switch', false)
const appUsage = useAppUsageStore()
const { switchValue, betaUiSwitchAvailable } = storeToRefs(appUsage)
const clearSwitchAndRedirect = (redirectTo: string) => {
switchValue.value = undefined
switchValue.value = null
window.location.href = redirectTo
setNeverAskAgainForTimedFeedback(false)
resetMilestoneHistory()
appUsage.setNeverAskAgainForTimedFeedback(false)
appUsage.resetMilestoneHistory()
appUsage.resetTotalAppUsageTime()
}
return { switchValue, clearSwitchAndRedirect, betaUiSwitchAvailable, user }
return { switchValue, clearSwitchAndRedirect, betaUiSwitchAvailable }
}
export const useBetaUi = () => {
const { switchValue, clearSwitchAndRedirect, betaUiSwitchAvailable, user } = initializeBetaUi()
const dismissValue = useLocalStorage('beta-ui-switch-dismiss', false)
const { switchValue, clearSwitchAndRedirect, betaUiSwitchAvailable } = initializeBetaUi()
const { dismissValue } = storeToRefs(useAppUsageStore())
const user = toRef(useSessionStore(), 'user')
const betaUiSwitchEnabled = computed(() => betaUiSwitchAvailable.value && !dismissValue.value)
@@ -49,7 +42,7 @@ export const useBetaUi = () => {
const { openFeedbackDialog } = useFeedbackDialog(EnumFeedbackDialog.SwitchBack)
const clearFeedbackConsentAndRedirect = (redirectTo: string) => {
hasFeedbackConsent.value = undefined
hasFeedbackConsent.value = null
clearSwitchAndRedirect(redirectTo)
}

View File

@@ -28,20 +28,26 @@ const descriptionOutput = computed(() => {
<template>
<div>
<CommonLabel class="text-gray-100 ltr:mr-1 rtl:ml-1 dark:text-neutral-400">
{{ capitalize($t(event.actionName)) }}
<CommonLabel v-if="event.sentenceDescription" class="me-1 text-gray-100 dark:text-neutral-400">
{{ $t(event.sentenceDescription) }}
</CommonLabel>
<CommonLabel v-if="descriptionOutput" class="text-gray-100 dark:text-neutral-400">{{
descriptionOutput
}}</CommonLabel>
<template v-else>
<CommonLabel class="me-1 text-gray-100 dark:text-neutral-400">
{{ capitalize($t(event.actionName)) }}
</CommonLabel>
<CommonLabel v-if="descriptionOutput" class="text-gray-100 dark:text-neutral-400">{{
descriptionOutput
}}</CommonLabel>
</template>
<CommonLabel
v-if="event.details"
class="max-w-md cursor-text rounded bg-neutral-200 px-0.5 font-mono break-word text-black dark:bg-gray-400 dark:text-white"
:class="{
'ltr:mr-1 rtl:ml-1': event.showSeparator || event.additionalDetails,
'ltr:ml-1 rtl:mr-1': descriptionOutput,
'me-1': event.showSeparator || event.additionalDetails,
'ms-1': descriptionOutput,
}"
>{{ event.details }}</CommonLabel
>
@@ -50,7 +56,7 @@ const descriptionOutput = computed(() => {
v-if="event.showSeparator && event.details && event.additionalDetails"
class="text-gray-100 dark:text-neutral-400"
:class="{
'ltr:mr-1 rtl:ml-1': event.details || event.additionalDetails,
'me-1': event.details || event.additionalDetails,
}"
>→</CommonLabel
>

View File

@@ -445,6 +445,7 @@ describe('useHistoryEvents', () => {
expect(getEventOutput(event)).toEqual({
actionName: 'notification',
component: undefined,
sentenceDescription: 'Notification sent to',
details: 'dummy@example.com',
additionalDetails: 'update:online,email',
})

View File

@@ -11,6 +11,7 @@ export default <EventActionModule>{
const match = notification.match(/^(?<email>[^(]+)\((?<details>[^)]+)\)$/)
return {
sentenceDescription: __('Notification sent to'),
details: match?.groups?.email,
additionalDetails: match?.groups?.details,
}

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