Compare commits
181 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7195a4a3e3 | ||
|
|
13217eb6b3 | ||
|
|
fe8fb9bd6c | ||
|
|
3b755736fd | ||
|
|
d1a5eabdd1 | ||
|
|
9a07d0b821 | ||
|
|
f7a97ea209 | ||
|
|
0dba387df8 | ||
|
|
34bdf46273 | ||
|
|
d51254f1c6 | ||
|
|
e3b0c8df91 | ||
|
|
849191c32a | ||
|
|
2be46473ee | ||
|
|
36fb4d9781 | ||
|
|
856af85c12 | ||
|
|
74b4fd4db6 | ||
|
|
f3e4da8362 | ||
|
|
a5878f37e4 | ||
|
|
dd22716ced | ||
|
|
fab2e2e9da | ||
|
|
beb4c67604 | ||
|
|
3af7e5e316 | ||
|
|
8f28689a42 | ||
|
|
a15cd172a7 | ||
|
|
ea8330b3b7 | ||
|
|
be4516bc93 | ||
|
|
dadb5f48db | ||
|
|
af9456d45d | ||
|
|
82c1433c9b | ||
|
|
129ad589c5 | ||
|
|
259923f25e | ||
|
|
54c6c4bb7f | ||
|
|
df4853d431 | ||
|
|
45d6580727 | ||
|
|
34dba5b33e | ||
|
|
8d2427e4f2 | ||
|
|
7808d16baf | ||
|
|
d50dab4694 | ||
|
|
018cbb1bf0 | ||
|
|
6be04bf6db | ||
|
|
043e0a5d88 | ||
|
|
f701041bb3 | ||
|
|
67dffc883d | ||
|
|
1ac064d58a | ||
|
|
82b813106f | ||
|
|
24c59d60cd | ||
|
|
1674d5ce65 | ||
|
|
ba1c0e39ba | ||
|
|
9e4da10164 | ||
|
|
82a63e5e1f | ||
|
|
487f3c3087 | ||
|
|
a82858faf1 | ||
|
|
bf8c7c4ea2 | ||
|
|
822af567bb | ||
|
|
e27a61311c | ||
|
|
9c0b19beb5 | ||
|
|
2c466b6036 | ||
|
|
6c1b14f348 | ||
|
|
429b7caf36 | ||
|
|
8450d40430 | ||
|
|
efcfe9f567 | ||
|
|
f1f6249cae | ||
|
|
8f573d3d22 | ||
|
|
0c29755e60 | ||
|
|
51afae3a48 | ||
|
|
b7790398b6 | ||
|
|
cdd01e9160 | ||
|
|
f78ef2434f | ||
|
|
2c3a6a06f9 | ||
|
|
a362bbace0 | ||
|
|
0e54acf658 | ||
|
|
1cf7f2d674 | ||
|
|
a2749f2db7 | ||
|
|
94d8a58938 | ||
|
|
954fda2e3c | ||
|
|
e79f8b44b9 | ||
|
|
0e34f3e410 | ||
|
|
292515f7e1 | ||
|
|
497fed7cda | ||
|
|
cbb2a4fbc5 | ||
|
|
f26deed718 | ||
|
|
234ce93925 | ||
|
|
69568e3432 | ||
|
|
c694208b68 | ||
|
|
1c321bf58e | ||
|
|
b098c818e0 | ||
|
|
ecbb861ce3 | ||
|
|
cc23d5d8b1 | ||
|
|
98513be2ea | ||
|
|
a7867c1c50 | ||
|
|
fc3c8fbfa2 | ||
|
|
3f0d91eb52 | ||
|
|
9d13c3cb5b | ||
|
|
83039994ec | ||
|
|
098384851e | ||
|
|
e67fb26562 | ||
|
|
c15ec10ab0 | ||
|
|
aaebfb50f9 | ||
|
|
dec97a362f | ||
|
|
8e256a188c | ||
|
|
cd63093007 | ||
|
|
69be23546a | ||
|
|
72ecb15c21 | ||
|
|
04af94f8c1 | ||
|
|
fbe64141e4 | ||
|
|
177d289d2b | ||
|
|
4751db0fbe | ||
|
|
92500319d6 | ||
|
|
47ae201a50 | ||
|
|
fcdd5aa646 | ||
|
|
1ada861e65 | ||
|
|
31615d48ae | ||
|
|
d7de27ad0a | ||
|
|
fa8c6bf6c0 | ||
|
|
ff593da81e | ||
|
|
1f5d8846a4 | ||
|
|
e8776c95d1 | ||
|
|
f48696b1ec | ||
|
|
88660cab71 | ||
|
|
3d761e3981 | ||
|
|
ed8332190c | ||
|
|
d617991a2b | ||
|
|
41381d74aa | ||
|
|
285d020f3c | ||
|
|
afb03e31a0 | ||
|
|
8e49cc85c6 | ||
|
|
fd02b7adbf | ||
|
|
666f67d9b0 | ||
|
|
d2746e06de | ||
|
|
4f3873f141 | ||
|
|
01f6586a9a | ||
|
|
ff041109c4 | ||
|
|
8b8dcc9d78 | ||
|
|
c9d09c26a3 | ||
|
|
baba7471ce | ||
|
|
65347c3a0e | ||
|
|
4fcf7c937a | ||
|
|
6709f4994d | ||
|
|
fe90397894 | ||
|
|
7feb242648 | ||
|
|
f57794f98b | ||
|
|
3a3c03ceab | ||
|
|
fed1264c5b | ||
|
|
c8f94c9705 | ||
|
|
65051ec1c4 | ||
|
|
8d5b2f464c | ||
|
|
590c39a5f7 | ||
|
|
cde5d36b71 | ||
|
|
ade27ca7f5 | ||
|
|
c0b88b7b88 | ||
|
|
b2f1568e27 | ||
|
|
5e21c42fc6 | ||
|
|
b2b0c82d20 | ||
|
|
05586ca634 | ||
|
|
e5571a4f27 | ||
|
|
162546ff79 | ||
|
|
ec6a1c3fe0 | ||
|
|
7099414ba9 | ||
|
|
9a9f4cf409 | ||
|
|
fff0170b7f | ||
|
|
de5dc41958 | ||
|
|
198d156069 | ||
|
|
d20dd0777c | ||
|
|
7c8b6b8d03 | ||
|
|
398c7ec90a | ||
|
|
18c5e90601 | ||
|
|
0822edfb73 | ||
|
|
9eeede7d56 | ||
|
|
dfba299ad8 | ||
|
|
ff20694223 | ||
|
|
f5324cee41 | ||
|
|
10cc4c2512 | ||
|
|
a2f7757274 | ||
|
|
d5d8c9870e | ||
|
|
de5733f6c9 | ||
|
|
5d56b3e8fe | ||
|
|
67b3844a5d | ||
|
|
f7fb3533cb | ||
|
|
3c02f5efd9 | ||
|
|
5b57861d05 | ||
|
|
ce2edd0f0c |
@@ -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
|
||||
|
||||
36
.dev/rubocop/cop/zammad/forbid_unprocessable_entity.rb
Normal file
36
.dev/rubocop/cop/zammad/forbid_unprocessable_entity.rb
Normal 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
|
||||
@@ -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'
|
||||
|
||||
3
.github/workflows/packager.io.yaml
vendored
3
.github/workflows/packager.io.yaml
vendored
@@ -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
5
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
63
.pkgr.yml
63
.pkgr.yml
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
169
Gemfile.lock
169
Gemfile.lock
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %> <<%= email_address.email %>></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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %> <<%= email_address.email %>></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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
80
app/controllers/integration/issue_tracker_controller.rb
Normal file
80
app/controllers/integration/issue_tracker_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '-')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user