Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
568ebbedd1 | ||
|
|
c81b1eef4f | ||
|
|
1c09be9296 | ||
|
|
a911ea6a28 | ||
|
|
0f6ba1064a | ||
|
|
6831317507 | ||
|
|
b835c523a1 | ||
|
|
9a22f3b275 | ||
|
|
b1d014adb5 | ||
|
|
1381cc4e8b | ||
|
|
7501080071 | ||
|
|
14b274a351 | ||
|
|
4431b58ea3 | ||
|
|
2fbe854ef9 | ||
|
|
939212703c | ||
|
|
697f3c5eb5 | ||
|
|
62459de9fc | ||
|
|
4cf023fcb0 | ||
|
|
0409a03cf3 | ||
|
|
4c1b04f844 | ||
|
|
eae5fbf42c | ||
|
|
8fb3e08692 | ||
|
|
87b40c59d9 | ||
|
|
4e3fd8859b | ||
|
|
3157469414 | ||
|
|
df7da4ac78 | ||
|
|
e377c56cee | ||
|
|
593469e050 | ||
|
|
305ed40a50 | ||
|
|
b413157394 | ||
|
|
712dd8affb | ||
|
|
01ac049327 | ||
|
|
d5fbb8469c | ||
|
|
18d25cb535 | ||
|
|
85621b75ac | ||
|
|
e1c3f67dfd | ||
|
|
d28538d74a | ||
|
|
2d015252eb | ||
|
|
278d847f73 | ||
|
|
03ed62e92e | ||
|
|
6f90af12b0 | ||
|
|
4e774a2890 | ||
|
|
3d93dedb02 | ||
|
|
5b3504e854 | ||
|
|
198abe9293 | ||
|
|
81faa6f165 | ||
|
|
347a03641f | ||
|
|
f41c682452 | ||
|
|
7a2569d7cf | ||
|
|
6c734230d6 | ||
|
|
7e3b6f678a | ||
|
|
f59b0a0b32 | ||
|
|
9ff96f55df | ||
|
|
a0c0e64113 | ||
|
|
bf5ef50af2 | ||
|
|
d37fc4eed4 | ||
|
|
e1fdf81145 | ||
|
|
18f82aef55 | ||
|
|
040d0b75a5 | ||
|
|
8fdfddc6bf | ||
|
|
448b7e5902 | ||
|
|
e1c1430f4a | ||
|
|
7342b7877a | ||
|
|
a1dab8a8bb | ||
|
|
bb84c49e68 | ||
|
|
fde0613fcd | ||
|
|
cb777e7cb7 | ||
|
|
1062a9d365 | ||
|
|
cf6d8b72c5 | ||
|
|
36e3accbda | ||
|
|
5847453164 | ||
|
|
8d4b3694e5 | ||
|
|
527c0bbf6c | ||
|
|
fb300d117d | ||
|
|
ce06e08ee6 | ||
|
|
bb16fd5293 | ||
|
|
29b69d1ea5 | ||
|
|
eb1eaf3f0a | ||
|
|
c1979c5ab7 | ||
|
|
67d0f843af | ||
|
|
c21ad1b1e2 | ||
|
|
8902d10c31 | ||
|
|
0348f6dd6d | ||
|
|
10430cd1ce | ||
|
|
a777172334 | ||
|
|
8e7fea9624 |
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"pnpm": {
|
||||
"neverBuiltDependencies": []
|
||||
},
|
||||
"devDependencies": {
|
||||
"@frsource/cypress-plugin-visual-regression-diff": "^3.3.10",
|
||||
"@testing-library/cypress": "^10.0.3",
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "DX packages",
|
||||
"pnpm": {
|
||||
"neverBuiltDependencies": []
|
||||
},
|
||||
"dependencies": {
|
||||
"hygen": "^6.2.11"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
module RuboCop
|
||||
module Cop
|
||||
module Zammad
|
||||
# This spec ensures that time zones in system specs are set at the top level only.
|
||||
# The problem is if time zone changes in the middle of the spec, browser does not pick it up.
|
||||
# This leads to confusing issues when whole file vs specific portion are executed.
|
||||
#
|
||||
# Non-system tests are ignored. Timezone in pure RSPec tests can be set at any moment.
|
||||
#
|
||||
# @example
|
||||
# # bad
|
||||
# RSpec.describe 'Test', type: :system do
|
||||
# it 'is fine', time_zone: 'Europe/London' do
|
||||
# example
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# # good
|
||||
# RSpec.describe 'Test', type: :system, time_zone: 'Europe/London' do
|
||||
# it 'is fine' do
|
||||
# example
|
||||
# end
|
||||
# end
|
||||
class EnsuresTimeZoneAtSpecTopLevel < Base
|
||||
def_node_matcher :is_rspec_block_with_time_zone?, <<-PATTERN
|
||||
$(send _ {:describe :context :it :shared_examples} (_ ...) (hash <(pair (sym :time_zone) (str...))...>))
|
||||
PATTERN
|
||||
|
||||
def_node_matcher :is_system_type?, <<-PATTERN
|
||||
$(block (send (const _ :RSpec) :describe (_ ...) (hash <(pair (sym :type) (sym :system))...>)...)...)
|
||||
PATTERN
|
||||
|
||||
def_node_matcher :is_rspec_top_level_block?, <<-PATTERN
|
||||
$(send (const _ :RSpec) :describe...)
|
||||
PATTERN
|
||||
|
||||
MSG = 'RSpec system tests (aka Capybara) should set custom time zones at top level only'.freeze
|
||||
|
||||
def on_send(node)
|
||||
return if !is_rspec_block_with_time_zone?(node)
|
||||
return if is_rspec_top_level_block?(node)
|
||||
return if !in_system_spec?(node)
|
||||
|
||||
add_offense(node)
|
||||
end
|
||||
|
||||
def in_system_spec?(node)
|
||||
top_parent = node.parent
|
||||
loop do
|
||||
break if !top_parent
|
||||
return true if is_system_type?(top_parent)
|
||||
|
||||
top_parent = top_parent.parent
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -492,3 +492,8 @@ Zammad/ExistsDbStrategy:
|
||||
- "spec/**/*.rb"
|
||||
Exclude:
|
||||
- "spec/support/capybara/*.rb"
|
||||
|
||||
Zammad/EnsuresTimeZoneAtSpecTopLevel:
|
||||
Enabled: true
|
||||
Include:
|
||||
- 'spec/system/**/*.rb'
|
||||
|
||||
@@ -21,3 +21,4 @@ require_relative 'cop/zammad/timezone_default'
|
||||
require_relative 'cop/zammad/to_forbid_over_not_to_permit'
|
||||
require_relative 'cop/zammad/trigger_from_commit_hooks'
|
||||
require_relative 'cop/zammad/update_copyright'
|
||||
require_relative 'cop/zammad/ensures_time_zone_at_spec_top_level'
|
||||
|
||||
@@ -16,11 +16,15 @@
|
||||
"dependencies": {
|
||||
"requireindex": "^1.2.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"neverBuiltDependencies": []
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-eslint-plugin": "^5.5.1",
|
||||
"eslint-plugin-n": "^14.0.0",
|
||||
"mocha": "^11.1.0"
|
||||
"mocha": "^11.1.0",
|
||||
"vue-eslint-parser": "^9.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "12.x || 14.x || >= 16"
|
||||
|
||||
34
.eslint-plugin-zammad/pnpm-lock.yaml
generated
34
.eslint-plugin-zammad/pnpm-lock.yaml
generated
@@ -24,6 +24,9 @@ importers:
|
||||
mocha:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
vue-eslint-parser:
|
||||
specifier: ^9.4.3
|
||||
version: 9.4.3(eslint@8.57.1)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -460,6 +463,9 @@ packages:
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
log-symbols@4.1.0:
|
||||
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -602,6 +608,11 @@ packages:
|
||||
resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
|
||||
hasBin: true
|
||||
|
||||
semver@7.7.1:
|
||||
resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
serialize-javascript@6.0.2:
|
||||
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
|
||||
|
||||
@@ -667,6 +678,12 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
vue-eslint-parser@9.4.3:
|
||||
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
|
||||
engines: {node: ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: '>=6.0.0'
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1146,6 +1163,8 @@ snapshots:
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
log-symbols@4.1.0:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
@@ -1282,6 +1301,8 @@ snapshots:
|
||||
|
||||
semver@6.3.0: {}
|
||||
|
||||
semver@7.7.1: {}
|
||||
|
||||
serialize-javascript@6.0.2:
|
||||
dependencies:
|
||||
randombytes: 2.1.0
|
||||
@@ -1342,6 +1363,19 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.1.1
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@8.57.1):
|
||||
dependencies:
|
||||
debug: 4.3.5(supports-color@8.1.1)
|
||||
eslint: 8.57.1
|
||||
eslint-scope: 7.2.2
|
||||
eslint-visitor-keys: 3.4.3
|
||||
espree: 9.6.1
|
||||
esquery: 1.4.2
|
||||
lodash: 4.17.21
|
||||
semver: 7.7.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
15
.github/workflows/docker-ci.yaml
vendored
15
.github/workflows/docker-ci.yaml
vendored
@@ -44,6 +44,15 @@ jobs:
|
||||
run: |
|
||||
cd zammad-docker-compose
|
||||
docker load --input /tmp/zammad-local.tar
|
||||
docker compose -f docker-compose.yml -f docker-compose.override-local.yml up --detach
|
||||
docker compose cp .github/auto_wizard.json zammad-railsserver:/opt/zammad/tmp
|
||||
.github/tests.sh
|
||||
|
||||
cat - <<ENV_FILE >> .env
|
||||
IMAGE_REPO=zammad-local
|
||||
VERSION=latest
|
||||
ENV_FILE
|
||||
|
||||
.github/tests/setup/default.sh
|
||||
.github/tests/default.sh
|
||||
|
||||
docker compose ps
|
||||
# Ensure we are running with the correct image
|
||||
docker compose ps | grep zammad-local:latest
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
files:
|
||||
- pnpm-lock.yaml
|
||||
paths:
|
||||
- tmp/cache/corepack
|
||||
- tmp/cache/pnpm-store
|
||||
- tmp/cache/cypress
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
default:
|
||||
image: $CI_REGISTRY/docker/zammad-ci:3.2.7
|
||||
image: $CI_REGISTRY/docker/zammad-ci:3.2.8
|
||||
|
||||
tags:
|
||||
- docker
|
||||
|
||||
@@ -38,10 +38,6 @@
|
||||
IDOIT_API_ENDPOINT: 'http://ci-service-idoit:80'
|
||||
IDOIT_API_TOKEN: 'admin'
|
||||
|
||||
.variables_ftp:
|
||||
variables:
|
||||
FTP_URL: 'ftp://ci-service-ftp'
|
||||
|
||||
.variables_auth:
|
||||
variables:
|
||||
KEYCLOAK_BASE_URL: 'http://ci-service-auth:8080'
|
||||
@@ -112,10 +108,6 @@
|
||||
name: $CI_REGISTRY/docker/zammad-idoit:stable
|
||||
alias: ci-service-idoit
|
||||
|
||||
ftp:
|
||||
name: $CI_REGISTRY/docker/zammad-ftp:stable
|
||||
alias: ci-service-ftp
|
||||
|
||||
ldap:
|
||||
name: $CI_REGISTRY/docker/zammad-ldap:stable
|
||||
alias: ci-service-ldap
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Global variables added to the ENV of each job and service container.
|
||||
variables:
|
||||
FF_NETWORK_PER_BUILD: 1 # https://docs.gitlab.com/runner/configuration/feature-flags.html
|
||||
# For corepack download caching.
|
||||
COREPACK_HOME: ${CI_PROJECT_DIR}/tmp/cache/corepack
|
||||
# for faster translation loading
|
||||
Z_LOCALES: 'en-us:de-de'
|
||||
RAILS_ENV: 'test'
|
||||
|
||||
@@ -9,6 +9,6 @@ workflow:
|
||||
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
|
||||
when: never
|
||||
# Don't create branch pipelines for pushes to private branches (as there will also be a MR for them).
|
||||
- if: $CI_COMMIT_BRANCH =~ /^(private|cherry-pick-|renovate|dependabot)/ && $CI_PIPELINE_SOURCE == "push"
|
||||
- if: $CI_COMMIT_BRANCH =~ /^(private|cherry-pick-|revert-|renovate|dependabot)/ && $CI_PIPELINE_SOURCE == "push"
|
||||
when: never
|
||||
- when: always
|
||||
- when: always
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
stage: pre
|
||||
cache: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_REF_NAME =~ /^(private|cherry-pick-|renovate|dependabot)/
|
||||
- if: $CI_COMMIT_REF_NAME =~ /^(private|cherry-pick-|revert-|renovate|dependabot)/
|
||||
when: never
|
||||
- when: always
|
||||
before_script:
|
||||
|
||||
@@ -3,7 +3,7 @@ docker:
|
||||
stage: test
|
||||
interruptible: false
|
||||
rules:
|
||||
- if: $CI_COMMIT_REF_NAME =~ /^private/
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_COMMIT_REF_NAME =~ /^private/
|
||||
when: manual
|
||||
allow_failure: true
|
||||
- when: on_success
|
||||
@@ -33,10 +33,12 @@ docker:
|
||||
ports: !reset []
|
||||
COMPOSE_OVERRIDE_FILE
|
||||
|
||||
docker compose up --quiet-pull --detach
|
||||
docker compose cp .github/auto_wizard.json zammad-railsserver:/opt/zammad/tmp
|
||||
DISABLE_EXTERNAL_TESTS=1 .github/tests.sh
|
||||
.github/tests/setup/default.sh
|
||||
DISABLE_EXTERNAL_TESTS=1 .github/tests/default.sh
|
||||
|
||||
docker compose ps
|
||||
# Ensure we are running with the correct image
|
||||
docker compose ps | grep ${ZAMMAD_IMAGE}
|
||||
after_script:
|
||||
- cd zammad-docker-compose
|
||||
- docker compose down --timeout 0 --volumes
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- !reference [.scripts, pnpm_install_eslint_plugin_zammad]
|
||||
script:
|
||||
# Only run one worker to avoid load peaks affecting other jobs.
|
||||
- pnpm test -- --min-workers=1 --max-workers=1 --shard $VITEST_SHARD
|
||||
- pnpm test --min-workers=1 --max-workers=1 --shard $VITEST_SHARD
|
||||
# Stop here, except on the first shard.
|
||||
- "[[ $VITEST_SHARD = 1/* ]] || exit 0"
|
||||
- echo "Also test eslint-plugin-zammad…"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- !reference [.scripts, zammad_db_init]
|
||||
# Then, switch to the current commit, migrate to it and run a few selected tests.
|
||||
- git checkout $CI_COMMIT_SHA
|
||||
- rvm use 3.2.7
|
||||
- rvm use 3.2.8
|
||||
- !reference [.scripts, bundle_install]
|
||||
# Force redis usage, even if it was disabled by the initial configure_environment script of stable.
|
||||
- export REDIS_URL=redis://redis
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
stage: test
|
||||
interruptible: false
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "schedule" || $CI_PIPELINE_SOURCE == "web"
|
||||
# These jobs can only run for a) published paths and b) when triggered after the packager.io build is complete.
|
||||
- if: ($CI_PIPELINE_SOURCE == "schedule" || $CI_PIPELINE_SOURCE == "web") && $CI_COMMIT_REF_NAME =~ /^(stable|develop)/
|
||||
services: []
|
||||
cache: []
|
||||
before_script: []
|
||||
|
||||
@@ -2,10 +2,12 @@ performance:
|
||||
stage: test
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "schedule" || $CI_PIPELINE_SOURCE == "web"
|
||||
allow_failure: true
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
changes:
|
||||
- .gitlab/ci/test/performance.yml
|
||||
- .gitlab/performance/**/*
|
||||
allow_failure: true
|
||||
# Make sure this gets executed exclusively on a runner.
|
||||
tags: ['no_concurrency']
|
||||
services:
|
||||
@@ -31,7 +33,7 @@ performance:
|
||||
- bundle exec rails r tmp/performance/run_tests.rb
|
||||
# Then, switch to the current commit, migrate to it and run a few selected tests.
|
||||
- git checkout $CI_COMMIT_SHA
|
||||
- rvm use 3.2.7
|
||||
- rvm use 3.2.8
|
||||
- !reference [.scripts, bundle_install]
|
||||
# Force redis usage, even if it was disabled by the initial configure_environment script of stable.
|
||||
- export REDIS_URL=redis://redis
|
||||
|
||||
@@ -21,7 +21,6 @@ rspec:integration:
|
||||
- .job_rules_integration
|
||||
- .variables_proxy
|
||||
- .variables_mattermost
|
||||
- .variables_ftp
|
||||
- .variables_s3
|
||||
allow_failure: true
|
||||
services:
|
||||
@@ -31,7 +30,6 @@ rspec:integration:
|
||||
- !reference [.services, redis]
|
||||
- !reference [.services, memcached]
|
||||
- !reference [.services, proxy]
|
||||
- !reference [.services, ftp]
|
||||
- !reference [.services, mattermost]
|
||||
- !reference [.services, ldap]
|
||||
- !reference [.services, s3]
|
||||
|
||||
@@ -4,7 +4,7 @@ set -eu
|
||||
|
||||
cd "$(dirname $0)"
|
||||
|
||||
docker compose down --timeout 0 --volumes
|
||||
docker compose down --timeout 0 --volumes || true # Ignore "prune operation is already running" errors.
|
||||
|
||||
DELETE_IMAGES=$(docker image ls "zammad-packagerio-ci-${CI_JOB_ID}" -q)
|
||||
if [ -n "$DELETE_IMAGES" ]
|
||||
|
||||
@@ -21,6 +21,6 @@ do
|
||||
docker compose down --timeout 0
|
||||
docker compose up -d
|
||||
docker compose exec zammad bash "/scenarios/${SCENARIO}/${DISTRIBUTION}.sh"
|
||||
docker compose down --timeout 0
|
||||
docker compose down --timeout 0 --volumes || true # Ignore "prune operation is already running" errors.
|
||||
done
|
||||
./cleanup.sh
|
||||
@@ -1 +1 @@
|
||||
3.2.7
|
||||
3.2.8
|
||||
|
||||
205
BREAKING_CHANGES.md
Normal file
205
BREAKING_CHANGES.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Breaking Changes
|
||||
|
||||
## 6.5
|
||||
|
||||
### Textarea object manager attribute values
|
||||
|
||||
When used as [template variables](https://admin-docs.zammad.org/en/latest/misc/variables.html), the `textarea` object manager attributes are now replaced with an HTML representation of their value. This is a consequence of a bugfix for [#5330](https://github.com/zammad/zammad/issues/5330), which expects newline characters are respected for these attributes in all contexts, including the rich text article body. The administrators are advised to check for usage of such variables in their objects (e.g. triggers, text modules, etc), by making sure the new value type will not break their existing workflows.
|
||||
|
||||
### Changes to search API endpoints
|
||||
|
||||
All search endpoints (`/:object/search`) have been revised, extended and unified. This will also result in breaking changes in the existing endpoints. Be prepared that the structure in the responses may change (have a look at the [API documentation](https://docs.zammad.org/en/latest/api/intro.html) where you can find updated examples).
|
||||
|
||||
The **standard search** (e.g. `/ticket/search`) returned a hash for some objects, such as the ticket. This will no longer be the case.
|
||||
|
||||
The structure for the **expanded search** (e.g. `/ticket/search?expand=true`) remains the same.
|
||||
|
||||
The structure of the **full search** (e.g. `/ticket/search?full=true`) remains the same, but is supplemented by a `total_count`, which counts all results.
|
||||
|
||||
Some objects used an object-related hash key, such as `ticket_ids`. This is now always `record_ids`.
|
||||
|
||||
The **count search** (e.g. `/ticket/search?only_total_count=true`) is a new feature.
|
||||
|
||||
### API performance optimization of asset return data
|
||||
|
||||
Most API endpoints have some parameter called full=true which returns the data with all related assets. [In order to improve the front end performance](https://github.com/zammad/zammad/issues/5495), we decided to reduce certain assets which are populated on the login. Normally this parameter is mainly used to show additional data to our legacy app. We have some static data which rarely changes and are currently added on every object like:
|
||||
|
||||
- Ticket Priority
|
||||
- Ticket State
|
||||
- Ticket Group
|
||||
- Role
|
||||
|
||||
This information will not be added in the future any more when requesting a user or a ticket. If you have an external script which is for example requesting tickets with assets and using the group name or some role information out of the assets, you need to adjust your code to do either:
|
||||
|
||||
1. Do a separate request to the object specific endpoint e.g. `/api/v1/groups`
|
||||
2. Get the global assets before requesting the ticket e.g. `/api/v1/signshow`
|
||||
|
||||
### Limit to one `merged` state
|
||||
|
||||
It will not be possible to have more than one state of the `merged` type. Additional states will be changed to the `closed` type automatically with that update. If you want to prevent that, make sure to only have one state of the type `merged` before you make an update to the **next release**.
|
||||
|
||||
### `APP_RESTART_CMD` and Zammad self-shutdown
|
||||
|
||||
Zammad will now cause a self-shutdown of all running processes after certain configuration changes by default. It is the responsibility of the controlling process manager (e.g. Docker, Kubernetes, systemd) to bring them up again by way of a proper restart policy. This is the default for vanilla Docker, Kubernetes or Package deployments of Zammad.
|
||||
|
||||
The previous environment variable `APP_RESTART_CMD` is not supported any more.
|
||||
|
||||
For systems where this behaviour is not wanted, it can be disabled by setting the Zammad Setting `auto_shutdown` to `false`.
|
||||
|
||||
### `ZAMMAD_SESSION_JOBS_CONCURRENT` was renamed to `ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS`
|
||||
|
||||
The background worker shutdown handling [was improved](https://github.com/zammad/zammad/commit/b5141f6670920bd960ab4269dc80b1d09d7c8eb6)
|
||||
to be more graceful and allow processes to finish first.
|
||||
|
||||
Together with this change, the session jobs logic that computes the ticket overviews was moved to be
|
||||
a first-class background service. Therefore the name of its configuration variable changed
|
||||
from `ZAMMAD_SESSION_JOBS_CONCURRENT` to `ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS`. The old one will continue
|
||||
to work for now, but emit a deprecation warning.
|
||||
|
||||
### nginx configuration update
|
||||
|
||||
Please update your nginx configuration file to insert the line `proxy_set_header Host $http_host;` to the `location /ws` and `location /cable` sections like in the example below:
|
||||
|
||||
```diff
|
||||
@@ -38,10 +38,11 @@ server {
|
||||
# legacy web socket server
|
||||
location /ws {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
+ proxy_set_header Host $http_host;
|
||||
proxy_set_header CLIENT_IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_pass http://zammad-websocket;
|
||||
@@ -50,10 +51,11 @@ server {
|
||||
# action cable
|
||||
location /cable {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
+ proxy_set_header Host $http_host;
|
||||
proxy_set_header CLIENT_IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_pass http://zammad-railsserver;
|
||||
```
|
||||
|
||||
## 6.4
|
||||
|
||||
### JavaScript Package Manager Change
|
||||
|
||||
This version changes the JavaScript toolchains to be based on pnpm rather than yarn. This is only relevant for source installations, where you need to provide a recent version of pnpm in order to run the assets:precompile command.
|
||||
|
||||
### Changed CSV-format for user/organization import/export
|
||||
|
||||
We slightly changed the format of the CSV-files you use for importing/exporting users and organizations. If you somehow do that in an automated process via our REST-API, you will need to review that process before updating to Zammad 6.4. Check the details in our documentation.
|
||||
|
||||
## 6.3
|
||||
|
||||
### Knowledge base granular permissions setup changed
|
||||
|
||||
Zammad allows configuration of a granular permission structure for knowledge base access. Previous Zammad versions allowed to misconfigure this in cases of allowing agents editor access to only some, but not all categories: it was possible to grant editor access at a higher level in the category tree, and then restricting access to reader or none for sub-categories. This was not effective due to permission inheritance.
|
||||
|
||||
Such a misconfiguration is no longer possible in Zammad 6.3. Administrators with existing knowledge base granular permission structures should review their configuration to ensure that at top-level only reader access is granted, and editor access only for the relevant sub-categories.
|
||||
|
||||
### Permissions for Ticket State and Priority REST-API
|
||||
|
||||
With this release, we will introduce new permissions for the ticket state and priority management. It will no longer be possible to access the corresponding REST-API with "admin.object" permission. Existing roles and/or access tokens that are used for these specific REST-API endpoints need to be updated to include the new permissions ("admin.ticket_state" and "admin.ticket_priority").
|
||||
|
||||
## 6.2
|
||||
|
||||
### Default SSL Verification in UserAgent
|
||||
|
||||
The default SSL behaviour of the *UserAgent* class in Zammad was changed. Previously, it would not perform SSL verification unless explicitly requested. Now, it will perform SSL verification unless explicitly rejected.
|
||||
|
||||
This may cause issues on systems with custom addons using the *UserAgent* to access other systems via *https*, if these systems have self-signed certificates. In such cases, these certificates or the CA certificates used for their generation should be uploaded via the new *SSL Certificates* management screen of Zammad. Alternatively, custom code can be adjusted to pass *verify_ssl: false* to *UserAgent* calls to restore the old behaviour.
|
||||
|
||||
### Oversized Email Handling
|
||||
|
||||
The handling of emails larger than the size limit changed. Previously, Zammad would send a reply and save the emails locally in *var/spool/oversized_email* (if the setting *postmaster_send_reject_if_mail_too_large* is *true*). No ticket is created in this case.
|
||||
|
||||
The new behaviour is that Zammad sends the reply like before, but no longer creates files for these emails locally - they are discarded.
|
||||
|
||||
### Reserved Delimiter in Group Name
|
||||
|
||||
The double colon (`::`) is now a reserved delimiter in the group name, in order to facilitate nested structure for complex hierarchies. Previously, it was possible to freely use this set of characters as part of the group name, but now it is forbidden.
|
||||
|
||||
On existing systems, the group names that contain the now reserved delimiter will be renamed, with sets of double colons being replaced by a dash (`-`) during the migration process.
|
||||
|
||||
Additionally, existing custom group object attributes named *name_last* and *parent_id* will be renamed too, by adding an underscore in front (*_name_last* and *_parent_id*). This is due to these attributes now being part of the group model, requiring dedicated table columns under the reserved names.
|
||||
|
||||
### Disallowed URL Values in User's Name Attributes
|
||||
|
||||
Text values that resemble valid URI addresses are now disallowed for user's first and last name attributes. Previously, it was possible to save any text in these attributes. The administrators should take a note of this change since it can have an impact on existing user data.
|
||||
|
||||
No migration will be run for existing users on update to Zammad 6.2. In case there are user records that contain URLs in their name attributes, they will be sanitized during subsequent updates. No manual action from administrator will be required, as the URI scheme or protocol will be automatically stripped from offending values.
|
||||
|
||||
## 6.1
|
||||
|
||||
### New Organization Attribute `vip`
|
||||
|
||||
Zammad 6.1 creates a new `vip` attribute for organizations. For systems with previously created `vip` attributes, there is special caution needed. In case of `boolean` attributes, Zammad will adjust them to the new settings and keep using them. For attributes of other types, Zammad will rename the existing attribute to `_vip` and add a new `vip` boolean attribute. This may cause issues if the previous attribute was used in other parts of the system, e.g. Triggers. In such cases, the relevant configurations must be reviewed and adjusted.
|
||||
|
||||
### Support for the Unicorn Web Server is removed
|
||||
|
||||
It seems that the Unicorn Web Server is no longer really used with Zammad. Therefore, we have decided to remove it with Zammad 6.1 after it was deprecated in Zammad 6.0.
|
||||
|
||||
## 6.0
|
||||
|
||||
### nginx configuration update
|
||||
|
||||
When updating to Zammad 6.0 from a previous version, the system administrator needs to add some content to the configuration of the reverse proxy.
|
||||
|
||||
Example for Apache:
|
||||
|
||||
```apache
|
||||
ProxyPass /cable ws://127.0.0.1:3000/cable
|
||||
```
|
||||
|
||||
Example for nginx:
|
||||
|
||||
```nginx
|
||||
location /cable {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header CLIENT_IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_pass http://zammad-railsserver;
|
||||
}
|
||||
```
|
||||
|
||||
This is required to enable the new Rails Action Cable based realtime communication. You can find more details about this change at [this github commit](https://github.com/zammad/zammad/commit/d873b2a04bd172b90573170bc2d1d4ee75a47f02).
|
||||
|
||||
### Mandatory Redis Dependency
|
||||
|
||||
Starting with Zammad version 6.0, Redis is required to run Zammad. For package-based installations, the new dependency is automatically installed in the system during the update.
|
||||
|
||||
Note: Hosted customers do not need to take any further action regarding this change!
|
||||
|
||||
### Health Check API Changes
|
||||
|
||||
Starting with Zammad 6.0, the "health check" monitoring API at `/api/v1/monitoring/health_check` will no longer echo the used authentication token in the response payload.
|
||||
|
||||
### Excel Export Format Change
|
||||
|
||||
Starting with Zammad version 6.0, all Excel exports will be exported exclusively in xlsx format. The original xls format will no longer be supported.
|
||||
|
||||
### Storage location of unprocessable/oversized emails
|
||||
|
||||
Zammad stores emails that were unprocessable or rejected due to size constraints in the file system. The location of such emails changed from `tmp/unprocessable_mail` and `tmp/oversized_mail` to `var/spool/unprocessable_mail` and `var/spool/oversized_mail` within the `/opt/zammad` directory. Existing emails are automatically moved to the new location.
|
||||
|
||||
### Naming change in Token model and EmailAddress model
|
||||
|
||||
The `token` model is used to store access tokens and had field names which may have caused confusion for developers using them. Therefore, the field previously called `name:` now has the correct identifier `token:` (as it stores the actual token value), and the field previously called `label:` is now called `name:` for better consistency with other models. The `EmailAddress` model is used to information about email addresses Zammad receives mail for. For consistency reasons, its `realname:` field is now called `name:`.
|
||||
|
||||
This means that the attribute via the REST API also changes: from token or realname to "name".
|
||||
|
||||
### Docker image zammad-docker was archived
|
||||
|
||||
The repository [zammad-docker](https://github.com/zammad/zammad-docker) was intended for testing / development purposes only. This repository was archived and will receive no further updates. Please use [zammad-docker-compose](https://github.com/zammad/zammad-docker-compose) instead.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Change Log for Zammad 6.5.x
|
||||
# Change Log for Zammad 6.5.0
|
||||
|
||||
- [Release notes](https://zammad.com/en/releases/6-5-x)
|
||||
- [Release notes](https://zammad.com/en/releases/6-5)
|
||||
- [Implemented enhancements](https://github.com/zammad/zammad/issues?q=is%3Aclosed+milestone%3A6.5+(-type%3ABug+AND+-label%3Abug))
|
||||
- [Closed bugs](https://github.com/zammad/zammad/issues?q=is%3Aclosed+milestone%3A6.5+(type%3ABug+OR+label%3Abug))
|
||||
- [Full commit log](https://github.com/zammad/zammad/compare/6.4.1...6.5.x)
|
||||
- [File tree](https://github.com/zammad/zammad/tree/6.5.x)
|
||||
- [Full commit log](https://github.com/zammad/zammad/compare/6.4.2...6.5.0)
|
||||
- [File tree](https://github.com/zammad/zammad/tree/6.5.0)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM node:20-bookworm-slim AS node
|
||||
RUN npm -g install pnpm
|
||||
RUN npm -g install corepack && corepack enable pnpm
|
||||
RUN rm /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
|
||||
|
||||
FROM ruby:3.2.7-slim-bookworm AS builder
|
||||
FROM ruby:3.2.8-slim-bookworm AS builder
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG RAILS_ENV=production
|
||||
ARG ZAMMAD_DIR=/opt/zammad
|
||||
@@ -17,7 +17,7 @@ RUN contrib/docker/setup.sh builder
|
||||
|
||||
# note: zammad is currently incompatible to alpine because of:
|
||||
# https://github.com/docker-library/ruby/issues/113
|
||||
FROM ruby:3.2.7-slim-bookworm
|
||||
FROM ruby:3.2.8-slim-bookworm
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG ZAMMAD_USER=zammad
|
||||
ENV RAILS_ENV=production
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -3,7 +3,7 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
# core - base
|
||||
ruby '3.2.7'
|
||||
ruby '3.2.8'
|
||||
gem 'rails', '~> 7.2.0'
|
||||
|
||||
# core - rails additions
|
||||
|
||||
16
Gemfile.lock
16
Gemfile.lock
@@ -164,7 +164,7 @@ GEM
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
cbor (0.5.9.8)
|
||||
cgi (0.4.1)
|
||||
cgi (0.4.2)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
@@ -278,7 +278,7 @@ GEM
|
||||
ostruct
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
graphql (2.4.10)
|
||||
graphql (2.4.13)
|
||||
base64
|
||||
fiber-storage
|
||||
logger
|
||||
@@ -326,7 +326,7 @@ GEM
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.10.1)
|
||||
json (2.10.2)
|
||||
json-jwt (1.16.7)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
@@ -400,7 +400,7 @@ GEM
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nkf (0.2.0)
|
||||
nokogiri (1.18.3)
|
||||
nokogiri (1.18.5)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nori (2.7.1)
|
||||
@@ -527,7 +527,7 @@ GEM
|
||||
rspec-mocks (~> 3.12)
|
||||
rspec-support (~> 3.12)
|
||||
racc (1.8.1)
|
||||
rack (2.2.11)
|
||||
rack (2.2.13)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-oauth2 (2.2.1)
|
||||
@@ -663,7 +663,7 @@ GEM
|
||||
hashie (~> 5.0.0)
|
||||
httparty (~> 0.21.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-saml (1.17.0)
|
||||
ruby-saml (1.18.0)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
rubyntlm (0.6.5)
|
||||
@@ -769,7 +769,7 @@ GEM
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.2)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
@@ -966,7 +966,7 @@ DEPENDENCIES
|
||||
zendesk_api
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.2.7p253
|
||||
ruby 3.2.8p263
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.1
|
||||
|
||||
@@ -261,7 +261,7 @@ class ChannelGroupEdit extends App.ControllerModal
|
||||
content: =>
|
||||
configureAttributesBase = [
|
||||
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
|
||||
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
]
|
||||
|
||||
@form = new App.ControllerForm(
|
||||
@@ -392,7 +392,7 @@ class ChannelEmailAccountWizard extends App.ControllerWizardModal
|
||||
{ name: 'email', display: __('Email'), tag: 'input', type: 'email', limit: 120, null: false, placeholder: 'support@example.com', autocapitalize: false, autocomplete: 'off' },
|
||||
{ name: 'password', display: __('Password'), tag: 'input', type: 'password', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', single: true },
|
||||
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
|
||||
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', null: false, options: @emailAddressOptions(@channel?.id, @channel?.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', null: false, options: @emailAddressOptions(@channel?.id, @channel?.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
]
|
||||
|
||||
@formMeta = new App.ControllerForm(
|
||||
@@ -401,6 +401,7 @@ class ChannelEmailAccountWizard extends App.ControllerWizardModal
|
||||
configure_attributes: configureAttributesBase
|
||||
className: ''
|
||||
params: @account.meta
|
||||
handlers: [@destinationGroupEmailAddressFormHandler()]
|
||||
)
|
||||
|
||||
# outbound
|
||||
@@ -422,7 +423,7 @@ class ChannelEmailAccountWizard extends App.ControllerWizardModal
|
||||
initializeInboundForm: (params) =>
|
||||
configureAttributesInbound = [
|
||||
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
|
||||
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', null: false, options: @emailAddressOptions(@channel?.id, @channel?.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', null: false, options: @emailAddressOptions(@channel?.id, @channel?.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'adapter', display: __('Type'), tag: 'select', multiple: false, null: false, options: @channelDriver.email.inbound, translate: true },
|
||||
{ name: 'options::host', display: __('Host'), tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false },
|
||||
{ name: 'options::user', display: __('User'), tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autocomplete: 'off' },
|
||||
@@ -753,6 +754,7 @@ class ChannelEmailAccountWizard extends App.ControllerWizardModal
|
||||
className: ''
|
||||
handlers: [
|
||||
App.FormHandlerChannelAccountArchiveMode.run
|
||||
App.FormHandlerChannelAccountArchiveBefore.run
|
||||
]
|
||||
params: options
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@ class App.ChannelInboundEmailArchive extends App.ControllerModal
|
||||
className: ''
|
||||
handlers: [
|
||||
App.FormHandlerChannelAccountArchiveMode.run
|
||||
App.FormHandlerChannelAccountArchiveBefore.run
|
||||
]
|
||||
attributePrefix: 'options::'
|
||||
params:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class App.FormHandlerChannelAccountArchiveBefore
|
||||
@run: (params, attribute, attributes, classname, form, ui) ->
|
||||
attributePrefix = ui.attributePrefix || ''
|
||||
return if attribute.name isnt "#{attributePrefix}archive" and attribute.name isnt "#{attributePrefix}archive_before"
|
||||
|
||||
archive = !!(params.options?.archive or params.archive)
|
||||
archiveBefore = params.options?.archive_before or params.archive_before
|
||||
|
||||
isAlertVisible = new Date(archiveBefore) > Date.now() if archiveBefore
|
||||
isAlertVisible = false if not archive
|
||||
|
||||
setTimeout(->
|
||||
$(form).closest('.modal-body,.wizard-body')
|
||||
.find('.js-archiveBeforeAlert')
|
||||
.toggleClass('hide', not isAlertVisible)
|
||||
0
|
||||
)
|
||||
@@ -279,7 +279,7 @@ class ChannelInboundEdit extends App.ControllerModal
|
||||
content: =>
|
||||
configureAttributesBase = [
|
||||
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
|
||||
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'options::folder', display: __('Folder'), tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, placeholder: __('optional') },
|
||||
{ name: 'options::keep_on_server', display: __('Keep messages on server'), tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false },
|
||||
]
|
||||
@@ -380,7 +380,7 @@ class ChannelGroupEdit extends App.ControllerModal
|
||||
content: =>
|
||||
configureAttributesBase = [
|
||||
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
|
||||
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
]
|
||||
@form = new App.ControllerForm(
|
||||
model:
|
||||
|
||||
@@ -32,7 +32,7 @@ class App.ChannelMicrosoft365 extends App.ControllerTabs
|
||||
|
||||
@alert = {
|
||||
variant: 'info'
|
||||
message: App.i18n.translateContent('Compared to the Microsoft 365 Graph API Email Channel, this is the traditional implementation using OAuth and IMAP. When setting up new channels, we suggest using the Graph API implementation instead. When you think there are good reasons to keep the IMAP approach as an alternative in the future, please let us know in the Zammad Community %l. More information can be found here %l.', 'https://community.zammad.org/t/microsoft-365-email-graphapi-vs-imap/16892', 'https://admin-docs.zammad.org/en/latest/channels/microsoft365/index.html')
|
||||
message: App.i18n.translateContent('Compared to the Microsoft 365 Graph API Email Channel, this is the traditional implementation using OAuth and IMAP. When setting up new channels, we suggest using the Graph API implementation instead. More information can be found here %l.', 'https://admin-docs.zammad.org/en/latest/channels/microsoft365/index.html')
|
||||
}
|
||||
|
||||
@render()
|
||||
@@ -301,7 +301,7 @@ class ChannelInboundEdit extends App.ControllerModal
|
||||
content: =>
|
||||
configureAttributesBase = [
|
||||
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
|
||||
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'options::folder', display: __('Folder'), tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false },
|
||||
{ name: 'options::keep_on_server', display: __('Keep messages on server'), tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false },
|
||||
]
|
||||
@@ -407,7 +407,7 @@ class ChannelGroupEdit extends App.ControllerModal
|
||||
content: =>
|
||||
configureAttributesBase = [
|
||||
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
|
||||
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
]
|
||||
@form = new App.ControllerForm(
|
||||
model:
|
||||
|
||||
@@ -268,7 +268,7 @@ class ChannelGroupEdit extends App.ControllerModal
|
||||
content: =>
|
||||
configureAttributesBase = [
|
||||
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
|
||||
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
]
|
||||
@form = new App.ControllerForm(
|
||||
model:
|
||||
@@ -478,7 +478,7 @@ class ChannelInboundEdit extends App.ControllerModal
|
||||
|
||||
configureAttributesBase = [
|
||||
{ name: 'group_id', display: __('Destination Group'), tag: 'tree_select', null: false, relation: 'Group', filter: { active: true } },
|
||||
{ name: 'group_email_address_id', display: __('Destination Group Email Address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'group_email_address_id', display: __('Destination group > Sending email address'), tag: 'select', null: false, options: @emailAddressOptions(@item.id, @item.group_id), note: __("This will adjust the corresponding setting of the destination group within the group management. A group's email address determines which address should be used for outgoing mails, e.g. when an agent is composing an email or a trigger is sending an auto-reply.") },
|
||||
{ name: 'options::folder_id', display: __('Folder'), tag: 'tree_select', null: true, options: @folderOptions, nulloption: true, default: '', help: __('Specify which folder to fetch from, or leave empty to fetch from ||inbox||.') },
|
||||
{ name: 'options::keep_on_server', display: __('Keep messages on server'), tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false },
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class System extends App.ControllerTabs
|
||||
@requiredPermission: 'admin.setting_system'
|
||||
@requiredPermission: 'admin.system'
|
||||
header: __('System')
|
||||
constructor: ->
|
||||
super
|
||||
@@ -16,4 +16,4 @@ class System extends App.ControllerTabs
|
||||
@tabs.push { name: __('Frontend'), 'target': 'ui', controller: App.SettingsArea, params: { area: 'System::UI' } }
|
||||
@render()
|
||||
|
||||
App.Config.set('SettingSystem', { prio: 1400, parent: '#settings', name: __('System'), target: '#settings/system', controller: System, permission: ['admin.setting_system'] }, 'NavBarAdmin')
|
||||
App.Config.set('SettingSystem', { prio: 1400, parent: '#settings', name: __('System'), target: '#settings/system', controller: System, permission: ['admin.system'] }, 'NavBarAdmin')
|
||||
|
||||
@@ -8,5 +8,9 @@ class CollectionAssets extends App.Controller
|
||||
App.Group.subscribe(->)
|
||||
App.TicketState.subscribe(->)
|
||||
App.TicketPriority.subscribe(->)
|
||||
if App.Session.get().permission('ticket.agent')
|
||||
App.Template.subscribe(->)
|
||||
App.ChecklistTemplate.subscribe(->)
|
||||
App.TicketSharedDraftStart.subscribe(->)
|
||||
|
||||
App.Config.set('collection_assets', CollectionAssets, 'Plugins')
|
||||
|
||||
@@ -63,8 +63,9 @@ class Modal extends App.ControllerModal
|
||||
@sent = true
|
||||
@update()
|
||||
|
||||
error: =>
|
||||
@contentInline = App.i18n.translateContent('Verification email could not be sent.')
|
||||
error: (data, status, xhr) =>
|
||||
details = data.responseJSON || {}
|
||||
@contentInline = details.error_human || details.error || __('Verification email could not be sent.')
|
||||
@update()
|
||||
|
||||
App.Config.set('user_signup', UserSignupCheck, 'Plugins')
|
||||
|
||||
@@ -93,6 +93,13 @@ class App.UiElement.ApplicationUiElement
|
||||
else
|
||||
@getConfigOptionList(attribute)
|
||||
|
||||
@getRelationOptionListSearchParams: (attribute) ->
|
||||
result =
|
||||
sortBy: attribute.sortBy
|
||||
if attribute.filter && _.isArray(attribute.filter) && attribute.tag is 'select'
|
||||
result.translate = attribute.translate
|
||||
result
|
||||
|
||||
@getRelationOptionList: (attribute, params) ->
|
||||
|
||||
# build options list based on relation
|
||||
@@ -101,6 +108,7 @@ class App.UiElement.ApplicationUiElement
|
||||
|
||||
attribute.options = []
|
||||
list = []
|
||||
searchParams = @getRelationOptionListSearchParams(attribute)
|
||||
if attribute.filter
|
||||
|
||||
App.Log.debug 'ControllerForm', '_getRelationOptionList:filter', attribute.filter
|
||||
@@ -109,7 +117,7 @@ class App.UiElement.ApplicationUiElement
|
||||
if typeof attribute.filter is 'function'
|
||||
App.Log.debug 'ControllerForm', '_getRelationOptionList:filter-function'
|
||||
|
||||
all = App[ attribute.relation ].search(sortBy: attribute.sortBy)
|
||||
all = App[ attribute.relation ].search(searchParams)
|
||||
|
||||
list = attribute.filter(all, 'collection', params)
|
||||
|
||||
@@ -120,7 +128,7 @@ class App.UiElement.ApplicationUiElement
|
||||
App.Log.debug 'ControllerForm', '_getRelationOptionList:filter-data', filter
|
||||
|
||||
# check all records
|
||||
for record in App[ attribute.relation ].search(sortBy: attribute.sortBy)
|
||||
for record in App[ attribute.relation ].search(searchParams)
|
||||
|
||||
# check all filter attributes
|
||||
for key in filter
|
||||
@@ -139,7 +147,7 @@ class App.UiElement.ApplicationUiElement
|
||||
filter.push(params[ attribute.name ])
|
||||
|
||||
# check all records
|
||||
for record in App[ attribute.relation ].search(sortBy: attribute.sortBy, translate: attribute.translate)
|
||||
for record in App[ attribute.relation ].search(searchParams)
|
||||
|
||||
# check all filter attributes
|
||||
for key in filter
|
||||
@@ -152,10 +160,10 @@ class App.UiElement.ApplicationUiElement
|
||||
# no data filter matched
|
||||
else
|
||||
App.Log.debug 'ControllerForm', '_getRelationOptionList:filter-data no filter matched'
|
||||
list = App[ attribute.relation ].search(sortBy: attribute.sortBy)
|
||||
list = App[ attribute.relation ].search(searchParams)
|
||||
else
|
||||
App.Log.debug 'ControllerForm', '_getRelationOptionList:filter-no filter defined'
|
||||
list = App[ attribute.relation ].search(sortBy: attribute.sortBy)
|
||||
list = App[ attribute.relation ].search(searchParams)
|
||||
|
||||
# Turn on attribute translation if configured for the relation object.
|
||||
if 'configure_translate' in App[attribute.relation]
|
||||
@@ -163,9 +171,23 @@ class App.UiElement.ApplicationUiElement
|
||||
|
||||
App.Log.debug 'ControllerForm', '_getRelationOptionList', attribute, list
|
||||
|
||||
if @isTreeRelation(attribute)
|
||||
@setTreeRelationData(list, attribute)
|
||||
|
||||
# build options list
|
||||
@buildOptionList(list, attribute)
|
||||
|
||||
@hasActiveChildren: (attribute, children, filter = undefined) ->
|
||||
return false if !children
|
||||
|
||||
for row in children
|
||||
return true if row.active && (!filter || _.contains(filter, row.id.toString()))
|
||||
|
||||
childrenActive = @hasActiveChildren(attribute, attribute.tree_children[row.id.toString()], filter)
|
||||
return true if childrenActive
|
||||
|
||||
return false
|
||||
|
||||
@buildOptionListRow: (attribute, item) ->
|
||||
|
||||
# check if element is selected, show it anyway - ignore active state
|
||||
@@ -176,7 +198,7 @@ class App.UiElement.ApplicationUiElement
|
||||
|
||||
hasActiveChildren = false
|
||||
if @isTreeRelation(attribute)
|
||||
hasActiveChildren = _.some(item.all_children(), (obj) -> obj.active)
|
||||
hasActiveChildren = @hasActiveChildren(attribute, attribute.tree_children[item.id.toString()])
|
||||
|
||||
# if active or if active doesn't exist
|
||||
return if !item.active && activeSupport && !isSelected && !hasActiveChildren
|
||||
@@ -361,3 +383,12 @@ class App.UiElement.ApplicationUiElement
|
||||
return false if !attribute.relation
|
||||
return false if !_.find(App[attribute.relation].configure_attributes, (attr) -> attr.name is 'parent_id')
|
||||
return true
|
||||
|
||||
@setTreeRelationData: (list, attribute) ->
|
||||
tree_children = {}
|
||||
for row in list
|
||||
parent_id = row.parent_id?.toString() || '-NONE-'
|
||||
tree_children[parent_id] ||= []
|
||||
tree_children[parent_id].push(row)
|
||||
|
||||
attribute.tree_children = tree_children
|
||||
|
||||
@@ -56,8 +56,7 @@ class App.UiElement.ApplicationTreeSelect extends App.UiElement.ApplicationUiEle
|
||||
activeChildren = false
|
||||
if option.value && option.children && option.children.length > 0
|
||||
if @isTreeRelation(attribute)
|
||||
all_children_ids = _.map(App[attribute.relation].find(option.value).all_children(), (group) -> group.id.toString())
|
||||
if _.intersection(attribute.filter, all_children_ids).length > 0
|
||||
if @hasActiveChildren(attribute, attribute.tree_children[option.value], attribute.filter)
|
||||
activeChildren = true
|
||||
else
|
||||
for value in attribute.filter
|
||||
@@ -81,18 +80,18 @@ class App.UiElement.ApplicationTreeSelect extends App.UiElement.ApplicationUiEle
|
||||
@buildOptionList: (list, attribute) ->
|
||||
return super if !@isTreeRelation(attribute)
|
||||
|
||||
parents = list.filter((obj) -> !obj.parent_id)
|
||||
attribute.options = @buildOptionListTreeRelation(list, attribute, parents)
|
||||
attribute.options = @buildOptionListTreeRelation(attribute, '-NONE-')
|
||||
attribute.sortBy = null
|
||||
|
||||
@buildOptionListTreeRelation: (list, attribute, entries) ->
|
||||
@buildOptionListTreeRelation: (attribute, parent_id) ->
|
||||
result = []
|
||||
for item in entries
|
||||
return result if !attribute.tree_children[parent_id]
|
||||
|
||||
for item in attribute.tree_children[parent_id]
|
||||
row = @buildOptionListRow(attribute, item)
|
||||
continue if !row
|
||||
|
||||
children = _.filter(list, (obj) -> obj.parent_id is item.id)
|
||||
children = @buildOptionListTreeRelation(list, attribute, children)
|
||||
children = @buildOptionListTreeRelation(attribute, item.id.toString())
|
||||
if children.length > 0
|
||||
row.children = children
|
||||
|
||||
|
||||
@@ -297,6 +297,7 @@ class GettingStartedChannelEmail extends App.ControllerWizardFullScreen
|
||||
className: ''
|
||||
handlers: [
|
||||
App.FormHandlerChannelAccountArchiveMode.run
|
||||
App.FormHandlerChannelAccountArchiveBefore.run
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ class App.KnowledgeBaseSidebarAttachments extends App.Controller
|
||||
attachments: @object.attachments
|
||||
)
|
||||
|
||||
@renderDragAndDropIfNeeded()
|
||||
|
||||
renderDragAndDropIfNeeded: ->
|
||||
return if @dragAndDropRendered
|
||||
|
||||
@dragAndDropRendered = true
|
||||
|
||||
html5Upload.initialize(
|
||||
uploadUrl: @object.generateURL('attachments')
|
||||
dropContainer: @el.get(0)
|
||||
|
||||
@@ -69,6 +69,7 @@ class PasswordReset extends App.ControllerFullPage
|
||||
data: JSON.stringify(params)
|
||||
processData: true
|
||||
success: @success
|
||||
error: @error
|
||||
)
|
||||
|
||||
success: (data) =>
|
||||
@@ -82,4 +83,13 @@ class PasswordReset extends App.ControllerFullPage
|
||||
public_links: public_links
|
||||
))
|
||||
|
||||
error: (data, statusText, error) =>
|
||||
@formEnable(@form.el)
|
||||
|
||||
details = data.responseJSON || {}
|
||||
@notify(
|
||||
type: 'error'
|
||||
msg: details.error_human || details.error || __('Loading failed.')
|
||||
)
|
||||
|
||||
App.Config.set('password_reset', PasswordReset, 'Routes')
|
||||
|
||||
@@ -22,6 +22,7 @@ class App.Search extends App.Controller
|
||||
@savedOrderBy = {}
|
||||
@resultPaginated = {}
|
||||
@result = {}
|
||||
@queue = {}
|
||||
|
||||
current = App.TaskManager.get(@taskKey).state
|
||||
if current && current.query
|
||||
@@ -67,19 +68,34 @@ class App.Search extends App.Controller
|
||||
show: (params) =>
|
||||
if @table
|
||||
@table.show()
|
||||
|
||||
@navupdate(url: '#search', type: 'menu')
|
||||
|
||||
if !_.isEmpty(params.query)
|
||||
if !_.isEmpty(params.query) # When opening detailed search from the global search
|
||||
@$('.js-search').val(params.query).trigger('keyup')
|
||||
return
|
||||
|
||||
if @query
|
||||
@search(500, true)
|
||||
else if @query # When coming back to detailed search taskbar from another taskbar
|
||||
@reloadCurrentSearch()
|
||||
|
||||
hide: ->
|
||||
if @table
|
||||
@table.hide()
|
||||
|
||||
reloadCurrentSearch: =>
|
||||
if !_.isEmpty(@getSavedOrderBy())
|
||||
modelsToLoad = _.keys(@savedOrderBy)
|
||||
modelsToLoad = _.without(modelsToLoad, @model)
|
||||
modelsToLoad.push('all')
|
||||
|
||||
@queue = { query: @query, models: modelsToLoad }
|
||||
|
||||
@goToPaginated(@model, @getSavedOrderBy().page)
|
||||
else
|
||||
modelsToLoad = _.keys(@savedOrderBy)
|
||||
|
||||
@queue = { query: @query, models: modelsToLoad }
|
||||
|
||||
@search(-1, true)
|
||||
|
||||
changed: ->
|
||||
# nothing
|
||||
|
||||
@@ -148,7 +164,7 @@ class App.Search extends App.Controller
|
||||
|
||||
@delayedRemoveAnyPopover()
|
||||
|
||||
search: (delay, force = false) =>
|
||||
search: (delay, force = false, skipRendering = false) =>
|
||||
query = @searchInput.val().trim()
|
||||
if !force
|
||||
return if !query
|
||||
@@ -164,8 +180,9 @@ class App.Search extends App.Controller
|
||||
delay = 200
|
||||
|
||||
@globalSearch.search(
|
||||
delay: delay
|
||||
query: @query
|
||||
delay: delay
|
||||
query: @query
|
||||
skipRendering: skipRendering
|
||||
)
|
||||
|
||||
buildResultCacheKey: (offset, direction, column, object) -> {
|
||||
@@ -174,26 +191,49 @@ class App.Search extends App.Controller
|
||||
|
||||
renderResult: (result = {}, params = undefined) =>
|
||||
if !_.isUndefined(params?.offset)
|
||||
@renderPaginatedSearchResult(result, params)
|
||||
else
|
||||
@renderInitialSearchResult(result, params)
|
||||
|
||||
for klassName, metadata of result
|
||||
@resultPaginated[klassName] ||= {}
|
||||
@loadNextInQueue()
|
||||
|
||||
cacheKey = @buildResultCacheKey(params?.offset, params?.orderDirection, params?.orderBy, klassName)
|
||||
@resultPaginated[klassName][cacheKey] = metadata.items
|
||||
renderPaginatedSearchResult: (result, params) =>
|
||||
for klassName, metadata of result
|
||||
@resultPaginated[klassName] ||= {}
|
||||
|
||||
if @model is klassName
|
||||
@renderTab(klassName, metadata.items || [])
|
||||
cacheKey = @buildResultCacheKey(params?.offset, params?.orderDirection, params?.orderBy, klassName)
|
||||
@resultPaginated[klassName][cacheKey] = metadata.items
|
||||
|
||||
return
|
||||
@result[klassName] ||= {}
|
||||
@result[klassName].total_count = metadata.total_count
|
||||
|
||||
if @model is klassName
|
||||
@renderTab(klassName, metadata.items || [])
|
||||
|
||||
renderInitialSearchResult: (result, params) =>
|
||||
@result = result
|
||||
# @savedOrderBy = {}
|
||||
for tab in @tabs
|
||||
count = result[tab.model]?.total_count || 0
|
||||
@$(".js-tab#{tab.model} .js-counter").text(count)
|
||||
|
||||
if @model is tab.model
|
||||
if !params?.skipRendering and @model is tab.model
|
||||
@renderTab(tab.model, result[tab.model]?.items || [])
|
||||
|
||||
loadNextInQueue: =>
|
||||
if @queue?.query != @query
|
||||
@queue = {}
|
||||
return
|
||||
|
||||
nextModel = @queue.models.shift()
|
||||
|
||||
if !nextModel
|
||||
@queue = {}
|
||||
else if nextModel is 'all'
|
||||
@search(-1, true, true)
|
||||
else
|
||||
@goToPaginated(nextModel, @savedOrderBy[nextModel]?.page)
|
||||
|
||||
showTab: (e) =>
|
||||
tabs = $(e.currentTarget).closest('.tabs')
|
||||
tabModel = $(e.currentTarget).data('tab-content')
|
||||
@@ -289,12 +329,11 @@ class App.Search extends App.Controller
|
||||
'click': callbackCheckbox
|
||||
select_all: callbackCheckbox
|
||||
sortClickCallback: @saveOrderBy
|
||||
pagerAjax: true
|
||||
)
|
||||
|
||||
updateSearch = =>
|
||||
callback = =>
|
||||
@search(0, true)
|
||||
@delay(callback, 100)
|
||||
@delay(@reloadCurrentSearch, 100)
|
||||
|
||||
@bulkForm.releaseController() if @bulkForm
|
||||
@bulkForm = new App.TicketBulkForm(
|
||||
@@ -352,20 +391,20 @@ class App.Search extends App.Controller
|
||||
events:
|
||||
'click': openObject
|
||||
sortClickCallback: @saveOrderBy
|
||||
pagerEnabled: false
|
||||
orderEnabled: false
|
||||
pagerAjax: true
|
||||
)
|
||||
|
||||
@renderPagination()
|
||||
|
||||
renderPagination: =>
|
||||
(@table.table || @table).pagerEnabled = false
|
||||
(@table.table || @table).orderEnabled = false
|
||||
|
||||
object = @el.find('.js-tab.active').data('tab-content')
|
||||
page = @getSavedOrderBy()?.page || 0
|
||||
count = @result[object]?.total_count || 0
|
||||
pages = Math.ceil(count / 50) - 1
|
||||
|
||||
if !pages
|
||||
if (!pages && !page) || count == 0
|
||||
@$('.js-pager').html('')
|
||||
return
|
||||
|
||||
@@ -415,9 +454,10 @@ class App.Search extends App.Controller
|
||||
@globalSearch.search(
|
||||
query: @query
|
||||
object:object
|
||||
offset: page * 50
|
||||
offset: (page || 0) * 50
|
||||
orderBy: savedOrder?.orderBy
|
||||
orderDirection: savedOrder?.orderDirection
|
||||
delay: -1
|
||||
)
|
||||
|
||||
updateTask: =>
|
||||
|
||||
@@ -309,7 +309,11 @@ class App.FormHandlerCoreWorkflow
|
||||
# this will use the form handler information to send the data to the backend via ajax/websockets
|
||||
@request: (classname, form, ui, attributes, params) ->
|
||||
requestID = "CoreWorkflow-#{Math.floor( Math.random() * 999999 ).toString()}"
|
||||
coreWorkflowRequests = {}
|
||||
|
||||
for key, value of coreWorkflowRequests
|
||||
continue if ui.idPrefix isnt value.ui.idPrefix
|
||||
delete coreWorkflowRequests[key]
|
||||
|
||||
coreWorkflowRequests[requestID] = { classname: classname, form: form, ui: ui, attributes: attributes, params: params }
|
||||
|
||||
requestData = {
|
||||
|
||||
@@ -6,7 +6,8 @@ class App.SidebarChecklistStart extends App.Controller
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
@subscribeId = App.ChecklistTemplate.subscribe(@render, initFetch: true)
|
||||
@subscribeId = App.ChecklistTemplate.subscribe(@render)
|
||||
@render()
|
||||
|
||||
release: =>
|
||||
App.ChecklistTemplate.unsubscribe(@subscribeId)
|
||||
|
||||
@@ -47,9 +47,11 @@ class App.UserProfileActionRow extends App.ControllerObserverActionRow
|
||||
msg: App.i18n.translateContent('Email sent to "%s". Please let the user verify their email account.', user.email)
|
||||
removeAll: true
|
||||
error: (data, status, xhr) =>
|
||||
details = data.responseJSON || {}
|
||||
|
||||
@notify
|
||||
type: 'error'
|
||||
msg: App.i18n.translateContent('Failed to send email to "%s". Please contact an administrator.', user.email)
|
||||
msg: details.error_human || details.error || __('Failed to send email to "%s". Please contact an administrator.', user.email)
|
||||
removeAll: true
|
||||
)
|
||||
|
||||
|
||||
@@ -194,9 +194,9 @@ PreDefinedWebhookMixin =
|
||||
# Make a deep clone of the pre-defined webhook field definition.
|
||||
attrs = $.extend(true, [], App[@genericObject].configure_attributes)
|
||||
|
||||
# Process edit forms conditionally, in case we are dealing with a pre-defined webhook.
|
||||
# Process edit and clone forms conditionally, in case we are dealing with a pre-defined webhook.
|
||||
if not @preDefinedWebhook and @item?.pre_defined_webhook_type
|
||||
@preDefinedWebhook = App.PreDefinedWebhook.find(@item.pre_defined_webhook_type)
|
||||
@preDefinedWebhook = App.PreDefinedWebhook.findByAttribute(name: @item.pre_defined_webhook_type)
|
||||
|
||||
# Add pre-defined webhook fields as additional attributes.
|
||||
if @preDefinedWebhook
|
||||
@@ -212,6 +212,12 @@ PreDefinedWebhookMixin =
|
||||
|
||||
{ configure_attributes: attrs }
|
||||
|
||||
# Inject the pre-defined webhook data into the edit and clone form.
|
||||
contentFormParams: ->
|
||||
return if not @item
|
||||
|
||||
$.extend(true, @item, { custom_payload: @preDefinedWebhook?.custom_payload if not @item.customized_payload })
|
||||
|
||||
WebhookSslVerifyAlertMixin =
|
||||
events:
|
||||
'change select[name="ssl_verify"]': 'handleSslVerifyAlert'
|
||||
@@ -259,11 +265,20 @@ class EditWebhook extends App.ControllerGenericEdit
|
||||
|
||||
setTimeout (=> @handleSslVerifyAlert()), 0
|
||||
|
||||
# Inject the pre-defined webhook data into the form.
|
||||
contentFormParams: ->
|
||||
$.extend(true, @item, { custom_payload: @preDefinedWebhook?.custom_payload if not @item.customized_payload })
|
||||
|
||||
class NewWebhook extends App.ControllerGenericNew
|
||||
@include PreDefinedWebhookMixin
|
||||
@include WebhookSslVerifyAlertMixin
|
||||
|
||||
shown: false
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
|
||||
App.PreDefinedWebhook.subscribe(@render, initFetch: true)
|
||||
|
||||
render: ->
|
||||
super
|
||||
|
||||
setTimeout (=> @handleSslVerifyAlert()), 0
|
||||
|
||||
App.Config.set('Webhook', { prio: 3350, name: __('Webhook'), parent: '#manage', target: '#manage/webhook', controller: Index, permission: ['admin.webhook'] }, 'NavBarAdmin')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class App.WidgetSharedDraft extends App.Controller
|
||||
constructor: ->
|
||||
super
|
||||
@load()
|
||||
@subscribeId = App.TicketSharedDraftStart.subscribe(@render)
|
||||
@render()
|
||||
|
||||
@@ -22,17 +21,6 @@ class App.WidgetSharedDraft extends App.Controller
|
||||
active_draft: active_draft
|
||||
)
|
||||
|
||||
load: =>
|
||||
@ajax
|
||||
id: 'shared_drafts_index'
|
||||
type: 'GET'
|
||||
url: @apiPath + '/tickets/shared_drafts'
|
||||
processData: true
|
||||
success: (data, status, xhr) =>
|
||||
App.TicketSharedDraftStart.deleteAll()
|
||||
App.Collection.loadAssets(data.assets)
|
||||
@render()
|
||||
|
||||
visibleDrafts: ->
|
||||
App.TicketSharedDraftStart.findAllByAttribute 'group_id', parseInt(@group_id)
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ class App.WidgetTemplate extends App.Controller
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
@subscribeId = App.Template.subscribe(@render, initFetch: true)
|
||||
@subscribeId = App.Template.subscribe(@render)
|
||||
@render()
|
||||
|
||||
release: =>
|
||||
App.Template.unsubscribe(@subscribeId)
|
||||
|
||||
@@ -3,6 +3,9 @@ class App.TicketList extends App.Controller
|
||||
@registerPopovers 'Organization', 'User'
|
||||
orderBy: null
|
||||
orderDirection: null
|
||||
pagerEnabled: false
|
||||
orderEnabled: false
|
||||
pagerAjax: false
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
@@ -130,6 +133,9 @@ class App.TicketList extends App.Controller
|
||||
bindCheckbox: @bindCheckbox
|
||||
radio: @radio
|
||||
sortClickCallback: @sortClickCallback
|
||||
pagerEnabled: @pagerEnabled
|
||||
orderEnabled: @orderEnabled
|
||||
pagerAjax: @pagerAjax
|
||||
)
|
||||
|
||||
@renderPopovers()
|
||||
|
||||
@@ -6,6 +6,9 @@ class App.Plugin
|
||||
_instance ?= new _pluginSingleton
|
||||
_instance.init(el)
|
||||
|
||||
@instance: ->
|
||||
_instance
|
||||
|
||||
class _pluginSingleton
|
||||
backends: {}
|
||||
el: undefined
|
||||
|
||||
@@ -97,6 +97,8 @@ class App.SearchableSelect extends Spine.Controller
|
||||
|
||||
@clear.on('click.searchable_select', @clearValue)
|
||||
|
||||
@el.addClass('searchableSelect-dropdown-up') if @attribute.direction == 'up'
|
||||
|
||||
renderSubmenus: (options) ->
|
||||
html = ''
|
||||
if options
|
||||
@@ -225,8 +227,6 @@ class App.SearchableSelect extends Spine.Controller
|
||||
onDropdownShown: =>
|
||||
@input.on('click', @stopPropagation)
|
||||
|
||||
@dropupSetTopOffsetIfNeeded()
|
||||
|
||||
@highlightFirst()
|
||||
if @level > 0
|
||||
@showSubmenu(@currentMenu)
|
||||
@@ -462,13 +462,7 @@ class App.SearchableSelect extends Spine.Controller
|
||||
duration: 240
|
||||
|
||||
if @attribute.direction == 'up'
|
||||
adjustBy = @currentMenu.height() - target_menu.height()
|
||||
|
||||
if adjustBy > 0
|
||||
target_menu.css('top', adjustBy)
|
||||
else if adjustBy < 0
|
||||
@currentMenu.css('top', adjustBy * -1)
|
||||
@dropdown.css('top', "-#{target_menu.height()}px")
|
||||
@dropdown.css('overflow-y', 'hidden')
|
||||
|
||||
@currentMenu.velocity
|
||||
properties:
|
||||
@@ -477,7 +471,7 @@ class App.SearchableSelect extends Spine.Controller
|
||||
duration: 240
|
||||
complete: =>
|
||||
if @attribute.direction == 'up'
|
||||
target_menu.css('top', 0)
|
||||
@dropdown.css('overflow-y', 'auto')
|
||||
|
||||
oldCurrentItem.removeClass('is-active')
|
||||
oldCurrentItem.removeClass('is-highlighted') if oldCurrentItem.hasClass('js-enter')
|
||||
@@ -486,7 +480,6 @@ class App.SearchableSelect extends Spine.Controller
|
||||
@dropdown.height(target_menu.height())
|
||||
@currentMenu = target_menu
|
||||
@animating = false
|
||||
@dropupSetTopOffsetIfNeeded()
|
||||
|
||||
showSubmenu: (menu) ->
|
||||
@currentMenu.prop('hidden', true)
|
||||
@@ -672,9 +665,6 @@ class App.SearchableSelect extends Spine.Controller
|
||||
else
|
||||
@highlightFirst(true)
|
||||
|
||||
if @isOpen
|
||||
@dropupSetTopOffsetIfNeeded()
|
||||
|
||||
addValueToShadowInput: (currentText, dataId) ->
|
||||
@resetSearch()
|
||||
|
||||
@@ -707,11 +697,6 @@ class App.SearchableSelect extends Spine.Controller
|
||||
@currentItem.removeClass('is-highlighted') if @currentItem.hasClass('js-enter')
|
||||
@currentItem = null
|
||||
|
||||
dropupSetTopOffsetIfNeeded: =>
|
||||
return if @attribute.direction != 'up'
|
||||
|
||||
@dropdown.css('top', "-#{@dropdown.find('ul:visible').height()}px")
|
||||
|
||||
toggleClear: =>
|
||||
if @attribute.value and not @isOpen
|
||||
@clear.removeClass('hide')
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
App.DestinationGroupEmailAddressesMixin =
|
||||
destinationGroupEmailAddressFormHandler: (item) ->
|
||||
formHandler = (params, attribute, attributes, classname, form, ui) =>
|
||||
return if not item?.id or attribute.name isnt 'group_id'
|
||||
return if attribute.name isnt 'group_id'
|
||||
|
||||
return if ui.FormHandlerGroupEmailAddressDone
|
||||
ui.FormHandlerGroupEmailAddressDone = true
|
||||
|
||||
$(form).find('[name=group_id]').off('change.group_id').on('change.group_id', (e) =>
|
||||
group_id = $(e.target).val()
|
||||
for attr in attributes
|
||||
continue if attr.name isnt 'group_email_address_id'
|
||||
attr.options = @emailAddressOptions(item.id, group_id)
|
||||
attr.options = @emailAddressOptions(item?.id, group_id)
|
||||
newElement = ui.formGenItem(attr, classname, form)
|
||||
form.find('div.form-group[data-attribute-name="' + attr.name + '"]').replaceWith(newElement)
|
||||
)
|
||||
@@ -18,13 +19,14 @@ App.DestinationGroupEmailAddressesMixin =
|
||||
formHandler
|
||||
|
||||
emailAddressOptions: (id, group_id) ->
|
||||
group = App.Group.find(group_id)
|
||||
|
||||
if !id
|
||||
return {
|
||||
false: App.i18n.translatePlain('Do not change email address')
|
||||
false: App.i18n.translatePlain('Do not change email address (%s)', group?.email_address?.email or '-')
|
||||
true: App.i18n.translatePlain('Change to channel email address')
|
||||
}
|
||||
|
||||
group = App.Group.find(group_id)
|
||||
emailAddresses = App.EmailAddress.findAllByAttribute('channel_id', id)
|
||||
|
||||
_.reduce(
|
||||
@@ -33,7 +35,7 @@ App.DestinationGroupEmailAddressesMixin =
|
||||
return acc if emailAddress.id is group?.email_address_id
|
||||
acc[emailAddress.id] = App.i18n.translatePlain('Change to %s', emailAddress.email)
|
||||
acc
|
||||
{ false: App.i18n.translatePlain('Do not change email address') }
|
||||
{ false: App.i18n.translatePlain('Do not change email address (%s)', group?.email_address?.email or '-') }
|
||||
)
|
||||
|
||||
processDestinationGroupEmailAddressParams: (params) ->
|
||||
|
||||
@@ -50,16 +50,6 @@ class App.Group extends App.Model
|
||||
signature_id_is_display_warning: (signature_id) ->
|
||||
!App.Signature.find(signature_id).active
|
||||
|
||||
all_children: ->
|
||||
result = []
|
||||
check_next = [@]
|
||||
while check_next.length > 0
|
||||
parent_ids = _.map(check_next, (group) -> group.id)
|
||||
children = _.filter(App.Group.all(), (group) -> _.contains(parent_ids, group.parent_id))
|
||||
result = result.concat(children)
|
||||
check_next = children
|
||||
result
|
||||
|
||||
displayName: =>
|
||||
name = @name || @name_last || '-'
|
||||
name.replaceAll('::', ' › ')
|
||||
|
||||
@@ -111,6 +111,9 @@
|
||||
<div class="modal-body">
|
||||
<div class="wizard-body vertical justified">
|
||||
<div class="alert alert--danger hide" role="alert"></div>
|
||||
<div class="alert alert--warning js-archiveBeforeAlert hide" role="alert">
|
||||
<%- @T('You have selected a cut-off time in the future. Be aware that all emails (including future ones) are going to be archived until the selected time is reached.') %>
|
||||
</div>
|
||||
<p class="js-messageFound"></p>
|
||||
<p><%- @T('You can import some of your emails as an "archive", which means that no notifications are sent and the tickets will be in a target state that you define.') %></p>
|
||||
<p><%- @T('You can find archived emails in Zammad anytime using the search function, like for any other ticket.') %></p>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<div>
|
||||
<div class="alert alert--danger hide" role="alert"></div>
|
||||
<div class="alert alert--warning js-archiveBeforeAlert hide" role="alert">
|
||||
<%- @T('You have selected a cut-off time in the future. Be aware that all emails (including future ones) are going to be archived until the selected time is reached.') %>
|
||||
</div>
|
||||
<% if @content_messages: %>
|
||||
<p><%- @T('%s email(s) were found in your mailbox. They will all be moved from your mailbox into Zammad.', @content_messages) %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<p><%- @T(@explanation) %></p>
|
||||
|
||||
<div class="js-pager"></div>
|
||||
<table class="table table--placeholder">
|
||||
<thead><tr><th><%- @T('No Entries') %>
|
||||
</table>
|
||||
</table>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
<div class="horizontal" role="group" aria-label="<%- @T('Pagination links') %>" style="margin-bottom: 5px;">
|
||||
<a href="#" class="btn btn--split--first btn--slim btn--small btn--secondary js-page<% if @page <= 0: %> is-disabled<% end %>" data-page="<%= @page - 1 %>" aria-label="<%- @T('Previous page') %>"<% if @page <= 0: %> aria-disabled="true"<% end %>>‹</a>
|
||||
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is 0: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" data-page="0" aria-label="<%- @T('Page %s', 1) %>">1</a>
|
||||
<% stepper = 2 %>
|
||||
<% stepper = 3 if @page is 1 || @page is @pages - 1 %>
|
||||
<% stepper = 4 if @page is 0 || @page is @pages %>
|
||||
<% if @pages > 1: %>
|
||||
<% for page in [1..@pages - 1]: %>
|
||||
<% continue if page > @page + stepper || page < @page - stepper %>
|
||||
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is page: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" aria-label="<%- @T('Page %s', page + 1) %>" aria-current="true" data-page="<%= page %>"><% if page is @page - stepper || page is @page + stepper: %>…<% else: %><%= page + 1 %><% end %></a>
|
||||
|
||||
<% stepper = 2 %>
|
||||
<% stepper = 3 if @page is 1 || @page is @pages - 1 %>
|
||||
<% stepper = 4 if @page is 0 || @page is @pages %>
|
||||
|
||||
<% if @pages > 1: %>
|
||||
<% for page in [1..@pages - 1]: %>
|
||||
<% continue if page > @page + stepper || page < @page - stepper %>
|
||||
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is page: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" aria-label="<%- @T('Page %s', page + 1) %>" aria-current="true" data-page="<%= page %>"><% if page is @page - stepper || page is @page + stepper: %>…<% else: %><%= page + 1 %><% end %></a>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is @pages: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" data-page="<%= @pages %>" aria-label="<%- @T('Page %s', @pages + 1) %>"><%= @pages + 1 %></a>
|
||||
|
||||
<% if @pages > 0: %>
|
||||
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is @pages: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" data-page="<%= @pages %>" aria-label="<%- @T('Page %s', @pages + 1) %>"><%= @pages + 1 %></a>
|
||||
<% end %>
|
||||
|
||||
<a href="#" class="btn btn--split--last btn--slim btn--small btn--secondary js-page<% if @page >= @pages: %> is-disabled<% end %>" data-page="<%= @page + 1 %>" aria-label="<%- @T('Next page') %>"<% if @page >= @pages: %> aria-disabled="true"<% end %>>›</a>
|
||||
</div>
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
<h2><%- @T('Archive Emails') %></h2>
|
||||
<div class="wizard-body vertical justified">
|
||||
<div class="alert alert--danger hide" role="alert"></div>
|
||||
<div class="alert alert--warning js-sslVerifyAlert hide" role="alert">
|
||||
<%- @T('Turning off SSL verification is a security risk and should be used only temporary. Use this option at your own risk!') %>
|
||||
<div class="alert alert--warning js-archiveBeforeAlert hide" role="alert">
|
||||
<%- @T('You have selected a cut-off time in the future. Be aware that all emails (including future ones) are going to be archived until the selected time is reached.') %>
|
||||
</div>
|
||||
<p class="js-messageFound"></p>
|
||||
<p><%- @T('You can import some of your emails as an "archive", which means that no notifications are sent and the tickets will be in a target state that you define.') %></p>
|
||||
|
||||
@@ -101,15 +101,15 @@
|
||||
</div>
|
||||
|
||||
<div class="action-controls">
|
||||
<% if channel.active is true: %>
|
||||
<div class="btn js-disable"><%- @T('Disable') %></div>
|
||||
<% else: %>
|
||||
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||
<% end %>
|
||||
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
|
||||
<% if channel.options.backup_imap_classic: %>
|
||||
<div class="btn btn--secondary js-rollbackMigration"><%- @T('Rollback migration') %></div>
|
||||
<% end %>
|
||||
<% if channel.active is true: %>
|
||||
<div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
|
||||
<% else: %>
|
||||
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||
<% end %>
|
||||
<div class="btn btn--secondary js-reauthenticate"><%- @T('Reauthenticate') %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,15 +111,15 @@
|
||||
</div>
|
||||
|
||||
<div class="action-controls">
|
||||
<% if channel.active is true: %>
|
||||
<div class="btn js-disable"><%- @T('Disable') %></div>
|
||||
<% else: %>
|
||||
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||
<% end %>
|
||||
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
|
||||
<% if channel.options.backup_imap_classic: %>
|
||||
<div class="btn btn--secondary js-rollbackMigration"><%- @T('Rollback migration') %></div>
|
||||
<% end %>
|
||||
<% if channel.active is true: %>
|
||||
<div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
|
||||
<% else: %>
|
||||
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||
<% end %>
|
||||
<div class="btn btn--secondary js-reauthenticate"><%- @T('Reauthenticate') %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9546,6 +9546,18 @@ li.dropdown-header {
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown.searchableSelect-dropdown-up > .dropdown-menu {
|
||||
top: auto;
|
||||
overflow-y: auto;
|
||||
bottom: 60px;
|
||||
max-height: calc(100vh - 60px);
|
||||
|
||||
.dropdown-submenu {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-title {
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -53,7 +53,9 @@ module ApplicationController::Authenticates
|
||||
raise Exceptions::Forbidden, 'API password access disabled!'
|
||||
end
|
||||
|
||||
auth = Auth.new(username, password)
|
||||
# Disable 2FA for iCal and calendar subscriptions
|
||||
only_verify_password = %w[/ical /calendar_subscriptions].any? { |path| request.path.start_with?(path) }
|
||||
auth = Auth.new(username, password, only_verify_password:)
|
||||
|
||||
begin
|
||||
auth.valid!
|
||||
|
||||
@@ -42,6 +42,11 @@ class ApplicationController::HasDownload::DownloadFile < SimpleDelegator
|
||||
|
||||
def file_content_type
|
||||
@file_content_type ||= preferences['Content-Type'] || preferences['Mime-Type'] || ActiveStorage.binary_content_type
|
||||
|
||||
# Workaround for https://github.com/rails/rails/issues/51842
|
||||
return @file_content_type if @file_content_type.start_with?('multipart/byteranges')
|
||||
|
||||
@file_content_type.split(';').first
|
||||
end
|
||||
|
||||
def content_inline?
|
||||
|
||||
@@ -24,7 +24,7 @@ module ApplicationController::HasUser
|
||||
def request_header_from
|
||||
@request_header_from ||= begin
|
||||
if request.headers['X-On-Behalf-Of'].present?
|
||||
ActiveSupport::Deprecation.warn("Header 'X-On-Behalf-Of' is deprecated. Please use header 'From' instead.")
|
||||
ActiveSupport::Deprecation.send(:warn, "Header 'X-On-Behalf-Of' is deprecated. Please use header 'From' instead.") # rubocop:disable Zammad/DetectTranslatableString
|
||||
end
|
||||
|
||||
request.headers['From'] || request.headers['X-On-Behalf-Of']
|
||||
|
||||
@@ -49,7 +49,7 @@ curl http://localhost/api/v1/groups -v -u #{login}:#{password}
|
||||
=end
|
||||
|
||||
def index
|
||||
model_index_render(Group.sorted, params)
|
||||
model_index_render(GroupPolicy::Scope.new(UserInfo.current_user, Group).resolve.sorted, params)
|
||||
end
|
||||
|
||||
=begin
|
||||
@@ -70,7 +70,7 @@ curl http://localhost/api/v1/groups/#{id} -v -u #{login}:#{password}
|
||||
=end
|
||||
|
||||
def show
|
||||
model_show_render(Group, params)
|
||||
model_show_render(GroupPolicy::Scope.new(UserInfo.current_user, Group).resolve, params)
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
@@ -21,7 +21,11 @@ class KnowledgeBase::AnswersController < KnowledgeBase::BaseController
|
||||
|
||||
if params[:include_contents].present?
|
||||
content_ids = params[:include_contents].split(',')
|
||||
contents = KnowledgeBase::Answer::Translation::Content.where id: content_ids
|
||||
|
||||
contents = KnowledgeBase::Answer::Translation::Content
|
||||
.joins(:translation)
|
||||
.where(knowledge_base_answer_translations: { answer_id: object.id })
|
||||
.where(id: content_ids)
|
||||
|
||||
assets = ApplicationModel::CanAssets.reduce contents, assets
|
||||
end
|
||||
|
||||
@@ -411,19 +411,14 @@ curl http://localhost/api/v1/users/password_reset -v -u #{login}:#{password} -H
|
||||
def password_reset_send
|
||||
raise Exceptions::UnprocessableEntity, 'username param needed!' if params[:username].blank?
|
||||
|
||||
send = Service::User::PasswordReset::Deprecated::Send.new(username: params[:username])
|
||||
|
||||
begin
|
||||
send.execute
|
||||
rescue Service::CheckFeatureEnabled::FeatureDisabledError => e
|
||||
raise Exceptions::UnprocessableEntity, e.message
|
||||
rescue Service::User::PasswordReset::Send::EmailError
|
||||
render json: { message: 'failed' }, status: :ok
|
||||
return
|
||||
end
|
||||
Service::User::PasswordReset::Deprecated::Send
|
||||
.new(username: params[:username])
|
||||
.execute
|
||||
|
||||
# Result is always positive to avoid leaking of existing user accounts.
|
||||
render json: { message: 'ok' }, status: :ok
|
||||
rescue Service::CheckFeatureEnabled::FeatureDisabledError => e
|
||||
raise Exceptions::UnprocessableEntity, e.message
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { useDateFormat } from '@vueuse/shared'
|
||||
import { shallowRef, reactive } from 'vue'
|
||||
|
||||
import type { FormRef } from '#shared/components/Form/types.ts'
|
||||
@@ -60,13 +59,6 @@ export const useEmailInboundMessagesForm = (
|
||||
help: __(
|
||||
'Emails before the cut-off time are imported as archived tickets. Emails after the cut-off time are imported as regular tickets.',
|
||||
),
|
||||
props: {
|
||||
// With an extra hour on top, so the current selection works.
|
||||
maxDate: useDateFormat(
|
||||
new Date(new Date().getTime() + 60 * 60 * 1000),
|
||||
'YYYY-MM-DDTHH:mm',
|
||||
).value,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'archive_state_id',
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { type Ref } from 'vue'
|
||||
|
||||
import {
|
||||
FormHandlerExecution,
|
||||
type ChangedField,
|
||||
type FormHandlerFunctionData,
|
||||
type FormHandlerFunctionReactivity,
|
||||
type FormValues,
|
||||
type FormSchemaField,
|
||||
type ReactiveFormSchemaDataField,
|
||||
} from '#shared/components/Form/types.ts'
|
||||
|
||||
import { useAlertFormHandler } from '../composables/useAlertFormHandler.ts'
|
||||
|
||||
import type { FormKitNode } from '@formkit/core'
|
||||
|
||||
const getReactivity = (
|
||||
changeFields?: Ref<Record<string, Partial<FormSchemaField>>>,
|
||||
fields?: Record<string, Partial<ReactiveFormSchemaDataField>>,
|
||||
values?: Record<string, unknown>,
|
||||
) =>
|
||||
({
|
||||
changeFields,
|
||||
schemaData: {
|
||||
fields: {
|
||||
...fields,
|
||||
},
|
||||
...values,
|
||||
},
|
||||
updateSchemaDataField: () => {},
|
||||
}) as unknown as FormHandlerFunctionReactivity
|
||||
|
||||
const getData = (changedField?: ChangedField, values?: FormValues) => {
|
||||
const mockedSet = vi.fn()
|
||||
const mockedRemove = vi.fn()
|
||||
|
||||
return {
|
||||
data: {
|
||||
formNode: {
|
||||
store: {
|
||||
set: mockedSet,
|
||||
remove: mockedRemove,
|
||||
},
|
||||
on: vi.fn(),
|
||||
} as unknown as FormKitNode,
|
||||
changedField,
|
||||
values,
|
||||
getNodeByName: vi.fn(),
|
||||
findNodeByName: vi.fn(),
|
||||
} as FormHandlerFunctionData,
|
||||
mockedSet,
|
||||
mockedRemove,
|
||||
}
|
||||
}
|
||||
|
||||
const key = 'fieldNameWarning'
|
||||
const message = { key }
|
||||
const eventHandler = vi.fn()
|
||||
|
||||
describe('useAlertFormHandler callback', () => {
|
||||
it('clears warning when callback returns false (initial)', async () => {
|
||||
const { callback } = useAlertFormHandler(
|
||||
'fieldName',
|
||||
message,
|
||||
() => false, // initialAddCallback
|
||||
() => false,
|
||||
eventHandler,
|
||||
)
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
fieldName: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'fieldName', value: undefined },
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(undefined, {
|
||||
fieldName: undefined,
|
||||
})
|
||||
|
||||
callback(FormHandlerExecution.InitialSettled, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalled()
|
||||
expect(mockedRemove).toBeCalledWith(key)
|
||||
})
|
||||
|
||||
it('adds warning when callback returns true (initial)', async () => {
|
||||
const { callback } = useAlertFormHandler(
|
||||
'fieldName',
|
||||
message,
|
||||
() => true, // initialAddCallback
|
||||
() => false,
|
||||
eventHandler,
|
||||
)
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
fieldName: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'fieldName', value: undefined },
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(undefined, {
|
||||
fieldName: undefined,
|
||||
})
|
||||
|
||||
callback(FormHandlerExecution.InitialSettled, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalledWith({
|
||||
blocking: false,
|
||||
key,
|
||||
type: 'warning',
|
||||
visible: true,
|
||||
})
|
||||
|
||||
expect(mockedRemove).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('clears warning when callback returns false (change)', async () => {
|
||||
const { callback } = useAlertFormHandler(
|
||||
'fieldName',
|
||||
message,
|
||||
() => false,
|
||||
() => false, // changeAddCallback
|
||||
eventHandler,
|
||||
)
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
fieldName: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'fieldName', value: undefined },
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(
|
||||
{
|
||||
name: 'fieldName',
|
||||
newValue: 'foobar',
|
||||
oldValue: undefined,
|
||||
},
|
||||
{ fieldName: undefined },
|
||||
)
|
||||
|
||||
callback(FormHandlerExecution.FieldChange, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalled()
|
||||
expect(mockedRemove).toBeCalledWith(key)
|
||||
})
|
||||
|
||||
it('adds warning when callback returns true (change)', async () => {
|
||||
const { callback } = useAlertFormHandler(
|
||||
'fieldName',
|
||||
message,
|
||||
() => false,
|
||||
() => true, // changeAddCallback
|
||||
eventHandler,
|
||||
)
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
fieldName: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'fieldName', value: undefined },
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(
|
||||
{
|
||||
name: 'fieldName',
|
||||
newValue: 'foobar',
|
||||
oldValue: undefined,
|
||||
},
|
||||
{ fieldName: undefined },
|
||||
)
|
||||
|
||||
callback(FormHandlerExecution.FieldChange, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalledWith({
|
||||
blocking: false,
|
||||
key,
|
||||
type: 'warning',
|
||||
visible: true,
|
||||
})
|
||||
|
||||
expect(mockedRemove).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('does not execute when another field is changed', async () => {
|
||||
const { callback } = useAlertFormHandler(
|
||||
'fieldName',
|
||||
message,
|
||||
() => false,
|
||||
() => false,
|
||||
eventHandler,
|
||||
)
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
fieldName: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: {
|
||||
name: 'fieldName',
|
||||
value: undefined,
|
||||
},
|
||||
},
|
||||
anotherField: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: {
|
||||
name: 'anotherField',
|
||||
value: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(
|
||||
{
|
||||
name: 'anotherField',
|
||||
newValue: 'foobar',
|
||||
oldValue: undefined,
|
||||
},
|
||||
{ fieldName: undefined, anotherField: 'foobar' },
|
||||
)
|
||||
|
||||
callback(FormHandlerExecution.FieldChange, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalled()
|
||||
expect(mockedRemove).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,312 @@
|
||||
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { type Ref } from 'vue'
|
||||
|
||||
import {
|
||||
FormHandlerExecution,
|
||||
type ChangedField,
|
||||
type FormHandlerFunctionData,
|
||||
type FormHandlerFunctionReactivity,
|
||||
type FormValues,
|
||||
type FormSchemaField,
|
||||
} from '#shared/components/Form/types.ts'
|
||||
|
||||
import { useArchiveBeforeWarningHandler } from '../composables/useArchiveBeforeWarningHandler.ts'
|
||||
|
||||
import type { FormKitNode } from '@formkit/core'
|
||||
import type { Except, SetOptional } from 'type-fest'
|
||||
|
||||
const getReactivity = (
|
||||
changeFields?: Ref<Record<string, Partial<FormSchemaField>>>,
|
||||
fields?: Record<
|
||||
string,
|
||||
{
|
||||
show: boolean
|
||||
updateFields: boolean
|
||||
props: Except<
|
||||
SetOptional<FormSchemaField, 'type'>,
|
||||
'show' | 'props' | 'updateFields' | 'relation'
|
||||
>
|
||||
}
|
||||
>,
|
||||
values?: Record<string, unknown>,
|
||||
) =>
|
||||
({
|
||||
changeFields,
|
||||
schemaData: {
|
||||
fields: {
|
||||
...fields,
|
||||
},
|
||||
...values,
|
||||
},
|
||||
updateSchemaDataField: () => {},
|
||||
}) as unknown as FormHandlerFunctionReactivity
|
||||
|
||||
const getData = (changedField?: ChangedField, values?: FormValues) => {
|
||||
const mockedSet = vi.fn()
|
||||
const mockedRemove = vi.fn()
|
||||
|
||||
return {
|
||||
data: {
|
||||
formNode: {
|
||||
store: {
|
||||
set: mockedSet,
|
||||
remove: mockedRemove,
|
||||
},
|
||||
on: vi.fn(),
|
||||
} as unknown as FormKitNode,
|
||||
changedField,
|
||||
values,
|
||||
getNodeByName: vi.fn(),
|
||||
findNodeByName: vi.fn(),
|
||||
} as FormHandlerFunctionData,
|
||||
mockedSet,
|
||||
mockedRemove,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useArchiveBeforeWarningHandler callback', () => {
|
||||
it('clears warning when archive_before is missing (initial)', async () => {
|
||||
const { callback } = useArchiveBeforeWarningHandler()
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
archive: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'archive', value: true },
|
||||
},
|
||||
archive_before: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'archive_before', value: undefined },
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(undefined, {
|
||||
archive: true,
|
||||
archive_before: undefined,
|
||||
})
|
||||
|
||||
callback(FormHandlerExecution.InitialSettled, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalled()
|
||||
expect(mockedRemove).toBeCalledWith('archiveBeforeWarning')
|
||||
})
|
||||
|
||||
it('clears warning when archive is turned off (initial)', async () => {
|
||||
const { callback } = useArchiveBeforeWarningHandler()
|
||||
|
||||
const dateInFuture = new Date(Date.now() + 1000).toISOString()
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
archive: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'archive', value: false },
|
||||
},
|
||||
archive_before: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'archive_before', value: dateInFuture },
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(undefined, {
|
||||
archive: false,
|
||||
archive_before: dateInFuture,
|
||||
})
|
||||
|
||||
callback(FormHandlerExecution.InitialSettled, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalled()
|
||||
expect(mockedRemove).toBeCalledWith('archiveBeforeWarning')
|
||||
})
|
||||
|
||||
it('clears warning when archive_before is cleared (change)', async () => {
|
||||
const { callback } = useArchiveBeforeWarningHandler()
|
||||
|
||||
const dateInFuture = new Date(Date.now() + 1000).toISOString()
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
archive: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'archive', value: true },
|
||||
},
|
||||
archive_before: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'archive_before', value: null },
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(
|
||||
{
|
||||
name: 'archive_before',
|
||||
newValue: null,
|
||||
oldValue: dateInFuture,
|
||||
},
|
||||
{ archive: false, archive_before: null },
|
||||
)
|
||||
|
||||
callback(FormHandlerExecution.FieldChange, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalled()
|
||||
expect(mockedRemove).toBeCalledWith('archiveBeforeWarning')
|
||||
})
|
||||
|
||||
it('clears warning when archive_before is in the past (change)', async () => {
|
||||
const { callback } = useArchiveBeforeWarningHandler()
|
||||
|
||||
const dateInPast = new Date(Date.now() - 1000).toISOString()
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
archive: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: {
|
||||
name: 'archive',
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
archive_before: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: {
|
||||
name: 'archive_before',
|
||||
value: dateInPast,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(
|
||||
{
|
||||
name: 'archive_before',
|
||||
newValue: dateInPast,
|
||||
oldValue: undefined,
|
||||
},
|
||||
{ archive: true, archive_before: dateInPast },
|
||||
)
|
||||
|
||||
callback(FormHandlerExecution.FieldChange, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalled()
|
||||
expect(mockedRemove).toBeCalledWith('archiveBeforeWarning')
|
||||
})
|
||||
|
||||
it('sets warning when archive_before is in the future (change)', async () => {
|
||||
const { callback } = useArchiveBeforeWarningHandler()
|
||||
|
||||
const dateInFuture = new Date(Date.now() + 1000).toISOString()
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
archive: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: {
|
||||
name: 'archive',
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
archive_before: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: {
|
||||
name: 'archive_before',
|
||||
value: dateInFuture,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(
|
||||
{
|
||||
name: 'archive_before',
|
||||
newValue: dateInFuture,
|
||||
oldValue: undefined,
|
||||
},
|
||||
{ archive: true, archive_before: dateInFuture },
|
||||
)
|
||||
|
||||
callback(FormHandlerExecution.FieldChange, reactivity, data)
|
||||
|
||||
expect(mockedSet).toHaveBeenCalledWith({
|
||||
blocking: false,
|
||||
key: 'archiveBeforeWarning',
|
||||
meta: {},
|
||||
type: 'warning',
|
||||
value:
|
||||
'You have selected a cut-off time in the future. Be aware that all emails (including future ones) are going to be archived until the selected time is reached.',
|
||||
visible: true,
|
||||
})
|
||||
|
||||
expect(mockedRemove).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('does not execute when archive_before is not present', async () => {
|
||||
const { callback } = useArchiveBeforeWarningHandler()
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
archive: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: { name: 'archive', value: false },
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(undefined, {
|
||||
archive: false,
|
||||
})
|
||||
|
||||
callback(FormHandlerExecution.InitialSettled, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalled()
|
||||
expect(mockedRemove).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('does not execute when another field is changed', async () => {
|
||||
const { callback } = useArchiveBeforeWarningHandler()
|
||||
|
||||
const dateInFuture = new Date(Date.now() + 1000).toISOString()
|
||||
|
||||
const reactivity = getReactivity(undefined, {
|
||||
archive: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: {
|
||||
name: 'archive',
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
archive_before: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: {
|
||||
name: 'archive_before',
|
||||
value: dateInFuture,
|
||||
},
|
||||
},
|
||||
archive_state_id: {
|
||||
show: true,
|
||||
updateFields: false,
|
||||
props: {
|
||||
name: 'archive_state_id',
|
||||
value: 4,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { data, mockedRemove, mockedSet } = getData(
|
||||
{
|
||||
name: 'archive_state_id',
|
||||
newValue: 4,
|
||||
oldValue: undefined,
|
||||
},
|
||||
{ archive: true, archive_before: dateInFuture, archive_state_id: 4 },
|
||||
)
|
||||
|
||||
callback(FormHandlerExecution.FieldChange, reactivity, data)
|
||||
|
||||
expect(mockedSet).not.toBeCalled()
|
||||
expect(mockedRemove).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { createMessage } from '@formkit/core'
|
||||
|
||||
import { FormHandlerExecution } from '#shared/components/Form/types.ts'
|
||||
import type {
|
||||
ChangedField,
|
||||
FormHandler,
|
||||
FormHandlerFunction,
|
||||
ReactiveFormSchemaDataField,
|
||||
ReactiveFormSchemData,
|
||||
} from '#shared/components/Form/types.ts'
|
||||
|
||||
import type { FormKitMessage, FormKitNode } from '@formkit/core'
|
||||
|
||||
export const useAlertFormHandler = (
|
||||
nodeName: string,
|
||||
message: Partial<FormKitMessage> & Pick<FormKitMessage, 'key'>,
|
||||
initialAddCallback: (
|
||||
field: ReactiveFormSchemaDataField,
|
||||
fields: Record<string, ReactiveFormSchemaDataField>,
|
||||
) => boolean,
|
||||
changeAddCallback: (
|
||||
field: ChangedField | undefined,
|
||||
fields: Record<string, ReactiveFormSchemaDataField>,
|
||||
) => boolean,
|
||||
eventHandler: (
|
||||
node: FormKitNode,
|
||||
addAlert: () => void,
|
||||
clearAlert: () => void,
|
||||
) => void,
|
||||
eventName: string = 'input',
|
||||
): FormHandler => {
|
||||
const addAlert = (formNode?: FormKitNode) => {
|
||||
formNode?.store.set(
|
||||
createMessage({
|
||||
blocking: false,
|
||||
type: 'warning',
|
||||
visible: true,
|
||||
...message,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const clearAlert = (formNode?: FormKitNode) => {
|
||||
formNode?.store.remove(message.key)
|
||||
}
|
||||
|
||||
const initializeNodeEvent = (node: FormKitNode, formNode: FormKitNode) => {
|
||||
node.on(eventName, ({ origin }) => {
|
||||
eventHandler(
|
||||
origin,
|
||||
() => addAlert(formNode),
|
||||
() => clearAlert(formNode),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const initializeFormNodeEvents = (formNode: FormKitNode) => {
|
||||
formNode.on('child.deep', ({ payload }) => {
|
||||
const childNode = payload as FormKitNode
|
||||
if (childNode.name !== nodeName) return
|
||||
|
||||
initializeNodeEvent(childNode, formNode)
|
||||
|
||||
childNode.on('destroying', () => {
|
||||
clearAlert(formNode)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const initializeNodeEvents = (
|
||||
execution: FormHandlerExecution,
|
||||
getNodeByName: (id: string) => FormKitNode | undefined,
|
||||
formNode?: FormKitNode,
|
||||
) => {
|
||||
if (execution === FormHandlerExecution.InitialSettled && formNode) {
|
||||
const node = getNodeByName(nodeName)
|
||||
|
||||
if (node) {
|
||||
initializeNodeEvent(node, formNode)
|
||||
|
||||
node.on('destroying', () => {
|
||||
clearAlert(formNode)
|
||||
initializeFormNodeEvents(formNode)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
initializeFormNodeEvents(formNode)
|
||||
}
|
||||
}
|
||||
|
||||
const executeHandler = (
|
||||
execution: FormHandlerExecution,
|
||||
schemaData: ReactiveFormSchemData,
|
||||
changedField?: ChangedField,
|
||||
formNode?: FormKitNode,
|
||||
) => {
|
||||
if (
|
||||
schemaData.fields[nodeName] === undefined ||
|
||||
schemaData.fields[nodeName] === null ||
|
||||
(execution === FormHandlerExecution.FieldChange &&
|
||||
(!changedField || changedField.name !== nodeName)) ||
|
||||
(typeof formNode?.find === 'function' && !formNode?.find(nodeName))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const alertFormHandler: FormHandlerFunction = (
|
||||
execution,
|
||||
reactivity,
|
||||
data,
|
||||
) => {
|
||||
const { changedField, formNode, getNodeByName } = data
|
||||
const { schemaData } = reactivity
|
||||
|
||||
initializeNodeEvents(execution, getNodeByName, formNode)
|
||||
|
||||
if (!executeHandler(execution, schemaData, changedField, formNode)) return
|
||||
|
||||
if (
|
||||
(execution === FormHandlerExecution.InitialSettled &&
|
||||
initialAddCallback(schemaData.fields[nodeName], schemaData.fields)) ||
|
||||
(execution === FormHandlerExecution.FieldChange &&
|
||||
changeAddCallback(changedField, schemaData.fields))
|
||||
) {
|
||||
addAlert(formNode)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clearAlert(formNode)
|
||||
}
|
||||
|
||||
return {
|
||||
execution: [
|
||||
FormHandlerExecution.InitialSettled,
|
||||
FormHandlerExecution.FieldChange,
|
||||
],
|
||||
callback: alertFormHandler,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { isFuture, parseISO } from 'date-fns'
|
||||
|
||||
import { i18n } from '#shared/i18n.ts'
|
||||
|
||||
import { useAlertFormHandler } from './useAlertFormHandler.ts'
|
||||
|
||||
export const useArchiveBeforeWarningHandler = () =>
|
||||
useAlertFormHandler(
|
||||
'archive_before',
|
||||
{
|
||||
key: 'archiveBeforeWarning',
|
||||
value: i18n.t(
|
||||
'You have selected a cut-off time in the future. Be aware that all emails (including future ones) are going to be archived until the selected time is reached.',
|
||||
),
|
||||
},
|
||||
(field, fields) =>
|
||||
Boolean(
|
||||
fields.archive.props.value &&
|
||||
field.props.value &&
|
||||
isFuture(parseISO(field.props.value as string)),
|
||||
),
|
||||
(changedField, fields) =>
|
||||
Boolean(
|
||||
fields.archive.props.value &&
|
||||
changedField?.name === 'archive_before' &&
|
||||
changedField.newValue &&
|
||||
isFuture(parseISO(changedField.newValue as string)),
|
||||
),
|
||||
(node, addAlert, clearAlert) => {
|
||||
const { value } = node
|
||||
|
||||
if (value && isFuture(parseISO(value as string))) addAlert()
|
||||
else clearAlert()
|
||||
},
|
||||
)
|
||||
@@ -1,134 +1,26 @@
|
||||
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { createMessage } from '@formkit/core'
|
||||
|
||||
import { FormHandlerExecution } from '#shared/components/Form/types.ts'
|
||||
import type {
|
||||
ChangedField,
|
||||
FormHandler,
|
||||
FormHandlerFunction,
|
||||
ReactiveFormSchemData,
|
||||
} from '#shared/components/Form/types.ts'
|
||||
import { i18n } from '#shared/i18n.ts'
|
||||
|
||||
import type { FormKitNode } from '@formkit/core'
|
||||
import { useAlertFormHandler } from './useAlertFormHandler.ts'
|
||||
|
||||
export const useSSLVerificationWarningHandler = (): FormHandler => {
|
||||
const addWarning = (formNode?: FormKitNode) => {
|
||||
formNode?.store.set(
|
||||
createMessage({
|
||||
blocking: false,
|
||||
key: 'sslVerificationWarning',
|
||||
type: 'warning',
|
||||
value: i18n.t(
|
||||
'Turning off SSL verification is a security risk and should be used only temporary. Use this option at your own risk!',
|
||||
),
|
||||
visible: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
export const useSSLVerificationWarningHandler = () =>
|
||||
useAlertFormHandler(
|
||||
'sslVerify',
|
||||
{
|
||||
key: 'sslVerificationWarning',
|
||||
value: i18n.t(
|
||||
'Turning off SSL verification is a security risk and should be used only temporary. Use this option at your own risk!',
|
||||
),
|
||||
},
|
||||
(field) => !field.props.disabled && field.props.value === false,
|
||||
(changedField, fields) =>
|
||||
!fields.sslVerify.props.disabled && changedField?.newValue === false,
|
||||
(node, addAlert, clearAlert) => {
|
||||
const { props, value } = node
|
||||
|
||||
const clearWarning = (formNode?: FormKitNode) => {
|
||||
formNode?.store.remove('sslVerificationWarning')
|
||||
}
|
||||
|
||||
const initializeSSLVerifyDisabledNodeEvent = (
|
||||
sslFieldNode: FormKitNode,
|
||||
formNode: FormKitNode,
|
||||
) => {
|
||||
sslFieldNode.on('prop:disabled', ({ origin }) => {
|
||||
const { props, value } = origin
|
||||
|
||||
if (props.disabled) clearWarning(formNode)
|
||||
else if (value === false) addWarning(formNode)
|
||||
})
|
||||
}
|
||||
|
||||
const initializeFormNodeEvents = (formNode: FormKitNode) => {
|
||||
formNode.on('child.deep', ({ payload }) => {
|
||||
const childNode = payload as FormKitNode
|
||||
if (childNode.name !== 'sslVerify') return
|
||||
|
||||
initializeSSLVerifyDisabledNodeEvent(childNode, formNode)
|
||||
|
||||
childNode.on('destroying', () => {
|
||||
clearWarning(formNode)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const initializeSSLVerifyNodeEvents = (
|
||||
execution: FormHandlerExecution,
|
||||
getNodeByName: (id: string) => FormKitNode | undefined,
|
||||
formNode?: FormKitNode,
|
||||
) => {
|
||||
if (execution === FormHandlerExecution.InitialSettled && formNode) {
|
||||
const sslFieldNode = getNodeByName('sslVerify')
|
||||
|
||||
if (sslFieldNode) {
|
||||
initializeSSLVerifyDisabledNodeEvent(sslFieldNode, formNode)
|
||||
|
||||
sslFieldNode.on('destroying', () => {
|
||||
clearWarning(formNode)
|
||||
initializeFormNodeEvents(formNode)
|
||||
})
|
||||
} else {
|
||||
initializeFormNodeEvents(formNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executeHandler = (
|
||||
execution: FormHandlerExecution,
|
||||
schemaData: ReactiveFormSchemData,
|
||||
changedField?: ChangedField,
|
||||
formNode?: FormKitNode,
|
||||
) => {
|
||||
if (
|
||||
schemaData.fields.sslVerify === undefined ||
|
||||
schemaData.fields.sslVerify === null ||
|
||||
(execution === FormHandlerExecution.FieldChange &&
|
||||
(!changedField || changedField.name !== 'sslVerify')) ||
|
||||
(typeof formNode?.find === 'function' && !formNode?.find('sslVerify'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSSLVerificationWarning: FormHandlerFunction = (
|
||||
execution,
|
||||
reactivity,
|
||||
data,
|
||||
) => {
|
||||
const { changedField, formNode, getNodeByName } = data
|
||||
const { schemaData } = reactivity
|
||||
|
||||
initializeSSLVerifyNodeEvents(execution, getNodeByName, formNode)
|
||||
|
||||
if (!executeHandler(execution, schemaData, changedField, formNode)) return
|
||||
|
||||
if (
|
||||
!schemaData.fields.sslVerify.props.disabled &&
|
||||
((execution === FormHandlerExecution.InitialSettled &&
|
||||
schemaData.fields.sslVerify.props.value === false) ||
|
||||
(execution === FormHandlerExecution.FieldChange &&
|
||||
changedField?.newValue === false))
|
||||
) {
|
||||
addWarning(formNode)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clearWarning(formNode)
|
||||
}
|
||||
|
||||
return {
|
||||
execution: [
|
||||
FormHandlerExecution.InitialSettled,
|
||||
FormHandlerExecution.FieldChange,
|
||||
],
|
||||
callback: handleSSLVerificationWarning,
|
||||
}
|
||||
}
|
||||
if (props.disabled) clearAlert()
|
||||
else if (value === false) addAlert()
|
||||
},
|
||||
'prop:disabled',
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
EmailOutboundData,
|
||||
EmailInboundMessagesData,
|
||||
} from '#desktop/entities/channel-email/types/email-inbound-outbound.ts'
|
||||
import { useArchiveBeforeWarningHandler } from '#desktop/form/composables/useArchiveBeforeWarningHandler.ts'
|
||||
import { useSSLVerificationWarningHandler } from '#desktop/form/composables/useSSLVerificationWarningHandler.ts'
|
||||
|
||||
import GuidedSetupActionFooter from '../../components/GuidedSetupActionFooter.vue'
|
||||
@@ -222,6 +223,7 @@ const emailConfigurationCheck = computed(() => {
|
||||
ref="formEmailInboundMessages"
|
||||
data-test-id="channel-email-inbound-messages"
|
||||
form-class="mb-2.5"
|
||||
:handlers="[useArchiveBeforeWarningHandler()]"
|
||||
:form-updater-id="
|
||||
EnumFormUpdaterId.FormUpdaterUpdaterGuidedSetupEmailArchive
|
||||
"
|
||||
|
||||
@@ -54,13 +54,13 @@ const notificationCount = computed(() => {
|
||||
</CommonLink>
|
||||
<CommonLink
|
||||
link="/account"
|
||||
class="group flex-1"
|
||||
class="group flex flex-1 justify-center"
|
||||
exact-active-class="user-active"
|
||||
>
|
||||
<CommonUserAvatar
|
||||
v-if="user"
|
||||
:entity="user"
|
||||
class="group-[.user-active]:ring-blue inline-flex group-[.user-active]:ring-2"
|
||||
class="group-[.user-active]:ring-blue group-[.user-active]:rounded-full group-[.user-active]:ring-2"
|
||||
size="small"
|
||||
personal
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"private": true,
|
||||
"pnpm": {
|
||||
"neverBuiltDependencies": []
|
||||
},
|
||||
"imports": {
|
||||
"#tests/*": "./tests/*",
|
||||
"#shared/*": "./shared/*",
|
||||
|
||||
@@ -184,19 +184,18 @@ export type FormSchemaNode =
|
||||
| FormSchemaFieldsForObjectAttributeScreen
|
||||
| string
|
||||
|
||||
export interface ReactiveFormSchemData {
|
||||
fields: Record<
|
||||
string,
|
||||
{
|
||||
show: boolean
|
||||
staticCondition: boolean
|
||||
updateFields: boolean
|
||||
props: Except<
|
||||
SetOptional<FormSchemaField, 'type'>,
|
||||
'show' | 'props' | 'updateFields' | 'relation'
|
||||
>
|
||||
}
|
||||
export interface ReactiveFormSchemaDataField {
|
||||
show: boolean
|
||||
staticCondition: boolean
|
||||
updateFields: boolean
|
||||
props: Except<
|
||||
SetOptional<FormSchemaField, 'type'>,
|
||||
'show' | 'props' | 'updateFields' | 'relation'
|
||||
>
|
||||
}
|
||||
|
||||
export interface ReactiveFormSchemData {
|
||||
fields: Record<string, ReactiveFormSchemaDataField>
|
||||
flags: Record<string, boolean>
|
||||
|
||||
[index: string]: unknown
|
||||
|
||||
@@ -21,15 +21,13 @@ module Gql::Mutations
|
||||
end
|
||||
|
||||
def resolve(username:)
|
||||
verify = Service::User::PasswordReset::Send.new(username: username)
|
||||
|
||||
begin
|
||||
verify.execute
|
||||
rescue Service::User::PasswordReset::Send::EmailError => e
|
||||
return error_response({ message: e.message })
|
||||
end
|
||||
Service::User::PasswordReset::Send
|
||||
.new(username: username)
|
||||
.execute
|
||||
|
||||
{ success: true }
|
||||
rescue Exceptions::UnprocessableEntity => e
|
||||
error_response({ message: e.message })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,14 +19,15 @@ module Gql::Mutations
|
||||
end
|
||||
|
||||
def resolve(input:)
|
||||
signup = Service::User::Signup.new(user_data: input.to_h)
|
||||
begin
|
||||
signup.execute
|
||||
rescue PasswordPolicy::Error => e
|
||||
return error_response({ message: e.message, field: 'password' })
|
||||
end
|
||||
Service::User::Signup
|
||||
.new(user_data: input.to_h)
|
||||
.execute
|
||||
|
||||
{ success: true }
|
||||
rescue PasswordPolicy::Error => e
|
||||
error_response({ message: e.message, field: 'password' })
|
||||
rescue Exceptions::UnprocessableEntity => e
|
||||
error_response({ message: e.message })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,6 +45,11 @@ class TicketArticleCommunicateEmailJob < ApplicationJob
|
||||
end
|
||||
channel = email_address.channel
|
||||
|
||||
if !channel.active
|
||||
log_error(record, "Channel defined for email address id '#{email_address.id}' is not active!", channel)
|
||||
return
|
||||
end
|
||||
|
||||
notification = false
|
||||
sender = Ticket::Article::Sender.lookup(id: record.sender_id)
|
||||
if sender['name'] == 'System'
|
||||
|
||||
@@ -68,17 +68,18 @@ class TriggerWebhookJob < ApplicationJob
|
||||
webhook.endpoint,
|
||||
payload,
|
||||
{
|
||||
json: true,
|
||||
jsonParseDisable: true,
|
||||
open_timeout: 4,
|
||||
read_timeout: 30,
|
||||
total_timeout: 60,
|
||||
headers: headers,
|
||||
signature_token: webhook.signature_token,
|
||||
verify_ssl: webhook.ssl_verify,
|
||||
user: webhook.basic_auth_username,
|
||||
password: webhook.basic_auth_password,
|
||||
log: {
|
||||
json: true,
|
||||
jsonParseDisable: true,
|
||||
open_timeout: 4,
|
||||
read_timeout: 30,
|
||||
total_timeout: 60,
|
||||
headers: headers,
|
||||
signature_token: webhook.signature_token,
|
||||
verify_ssl: webhook.ssl_verify,
|
||||
user: webhook.basic_auth_username,
|
||||
password: webhook.basic_auth_password,
|
||||
do_not_follow_redirects: true,
|
||||
log: {
|
||||
facility: 'webhook',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -193,6 +193,7 @@ stream all accounts
|
||||
|
||||
# start threads for each channel
|
||||
@@channel_stream[channel_id][:thread] = Thread.new do
|
||||
Thread.current.name = "stream channel for '#{channel.id}' (#{channel.area})..."
|
||||
|
||||
logger.info "Started stream channel for '#{channel.id}' (#{channel.area})..."
|
||||
channel.status_in = 'ok'
|
||||
|
||||
@@ -247,19 +247,19 @@ returns
|
||||
return MessageResult.new(success: false, after_action: [:result_error, notice])
|
||||
end
|
||||
|
||||
return MessageResult.new(sucess: false) if message_meta.nil?
|
||||
return MessageResult.new(success: false) if message_meta.nil?
|
||||
|
||||
message_validator = MessageValidator.new(self.class.extract_rfc822_headers(message_meta), message_meta.attr['RFC822.SIZE'])
|
||||
|
||||
if message_validator.fresh_verify_message?
|
||||
Rails.logger.info " - ignore message #{count}/#{count_all} - because message has a verify message"
|
||||
|
||||
return MessageResult.new(sucess: false)
|
||||
return MessageResult.new(success: false)
|
||||
end
|
||||
|
||||
# ignore deleted messages
|
||||
if deleted?(message_meta, count, count_all)
|
||||
return MessageResult.new(sucess: false)
|
||||
return MessageResult.new(success: false)
|
||||
end
|
||||
|
||||
# ignore already imported
|
||||
@@ -269,10 +269,10 @@ returns
|
||||
end
|
||||
Rails.logger.info " - ignore message #{count}/#{count_all} - because message message id already imported"
|
||||
|
||||
return MessageResult.new(sucess: false)
|
||||
return MessageResult.new(success: false)
|
||||
end
|
||||
|
||||
# delete email from server after article was created
|
||||
# fetch message body
|
||||
msg = begin
|
||||
Timeout.timeout(FETCH_MSG_TIMEOUT) do
|
||||
key = fetch_message_body_key(@options)
|
||||
@@ -284,7 +284,7 @@ returns
|
||||
end
|
||||
|
||||
if !msg
|
||||
return MessageResult.new(sucess: false)
|
||||
return MessageResult.new(success: false)
|
||||
end
|
||||
|
||||
# do not process too big messages, instead download & send postmaster reply
|
||||
@@ -304,6 +304,7 @@ returns
|
||||
process(@channel, msg, false)
|
||||
end
|
||||
|
||||
# delete email from server after article was created
|
||||
begin
|
||||
Timeout.timeout(FETCH_MSG_TIMEOUT) do
|
||||
if @keep_on_server
|
||||
|
||||
@@ -106,7 +106,7 @@ class Channel::Driver::Pop3 < Channel::Driver::BaseEmailInbound
|
||||
if message_validator.fresh_verify_message?
|
||||
Rails.logger.info " - ignore message #{count}/#{count_all} - because message has a verify message"
|
||||
|
||||
return MessageResult.new(sucess: false)
|
||||
return MessageResult.new(success: false)
|
||||
end
|
||||
|
||||
# do not process too large messages, instead download and send postmaster reply
|
||||
|
||||
@@ -13,9 +13,11 @@ class CoreWorkflow::Result::SetFixedTo < CoreWorkflow::Result::BaseOption
|
||||
end
|
||||
|
||||
def config_value
|
||||
result = Array(@perform_config['set_fixed_to'])
|
||||
result |= saved_value
|
||||
result
|
||||
@config_value ||= begin
|
||||
result = Array(@perform_config['set_fixed_to'])
|
||||
result |= saved_value
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def restriction_set?
|
||||
|
||||
@@ -12,10 +12,10 @@ class Group
|
||||
end
|
||||
|
||||
def authorized_asset?
|
||||
return true if UserInfo.assets.blank? || UserInfo.assets.agent? || Setting.get('customer_ticket_create_group_ids').blank?
|
||||
return true if UserInfo.current_user.blank?
|
||||
|
||||
allowed_group_ids = Auth::RequestCache.fetch_value("Group/Assets/authorized_asset/groups/#{UserInfo.current_user_id}") do
|
||||
Array.wrap(Setting.get('customer_ticket_create_group_ids')).map(&:to_i) | TicketPolicy::ReadScope.new(UserInfo.current_user).resolve.distinct(:group_id).pluck(:group_id)
|
||||
GroupPolicy::Scope.new(UserInfo.current_user, Group).resolve.pluck(:id)
|
||||
end
|
||||
|
||||
allowed_group_ids.include?(id)
|
||||
|
||||
@@ -151,6 +151,11 @@ reload config settings
|
||||
end
|
||||
|
||||
@@raw.each do |key, value|
|
||||
if key == 'notification_sender'
|
||||
@@current[key] = sanitize_notification_sender value
|
||||
next
|
||||
end
|
||||
|
||||
@@current[key] = interpolate_value value
|
||||
end
|
||||
end
|
||||
@@ -171,6 +176,24 @@ reload config settings
|
||||
end
|
||||
private_class_method :interpolate_value
|
||||
|
||||
def self.sanitize_notification_sender(input)
|
||||
return input if !input.is_a? String
|
||||
|
||||
input.gsub(%r{\#\{config\.(.+?)\}}) do
|
||||
placeholder = @@raw[$1].to_s
|
||||
|
||||
case $1
|
||||
when 'fqdn'
|
||||
placeholder = placeholder.sub(%r{:\d+$}, '') # strip port from fqdn
|
||||
when 'product_name'
|
||||
placeholder = "\"#{placeholder.gsub(%r{"}, "''")}\"" # quote and replace double quotes
|
||||
end
|
||||
|
||||
placeholder
|
||||
end
|
||||
end
|
||||
private_class_method :sanitize_notification_sender
|
||||
|
||||
# set initial value in state_initial
|
||||
def set_initial
|
||||
self.state_initial = state_current
|
||||
|
||||
@@ -10,9 +10,11 @@ class SystemReport::Plugin::Environment < SystemReport::Plugin
|
||||
'ZAMMAD_RAILS_PORT' => ENV['ZAMMAD_RAILS_PORT'].present?,
|
||||
'ZAMMAD_WEBSOCKET_PORT' => ENV['ZAMMAD_WEBSOCKET_PORT'].present?,
|
||||
'WEB_CONCURRENCY' => ENV['WEB_CONCURRENCY'],
|
||||
'ZAMMAD_SESSION_JOBS_CONCURRENT' => ENV['ZAMMAD_SESSION_JOBS_CONCURRENT'],
|
||||
'ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS' => ENV['ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS'],
|
||||
'ZAMMAD_PROCESS_SCHEDULED_JOBS_WORKERS' => ENV['ZAMMAD_PROCESS_SCHEDULED_JOBS_WORKERS'],
|
||||
'ZAMMAD_PROCESS_DELAYED_JOBS_WORKERS' => ENV['ZAMMAD_PROCESS_DELAYED_JOBS_WORKERS'],
|
||||
# deprecated settings
|
||||
'ZAMMAD_SESSION_JOBS_CONCURRENT' => ENV['ZAMMAD_SESSION_JOBS_CONCURRENT'],
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,17 @@ class SystemReport::Plugin::Hardware < SystemReport::Plugin
|
||||
end
|
||||
|
||||
def total_memory
|
||||
open3_data&.dig('children')&.find { |entry| entry['description'].downcase == 'motherboard' }&.dig('children')&.find { |entry| entry['description'].downcase == 'system memory' }&.dig('size')
|
||||
memory = open3_data&.dig('children')&.find { |entry| entry['description'].downcase == 'motherboard' }&.dig('children')&.find { |entry| entry['description'].downcase == 'system memory' }&.dig('size')
|
||||
|
||||
return memory if memory.present?
|
||||
|
||||
# Fallback to /proc/meminfo if lshw is missing or fails
|
||||
begin
|
||||
mem_kb = File.read('/proc/meminfo')[%r{MemTotal:\s+(\d+) kB}, 1].to_i
|
||||
mem_kb * 1024
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def df_zammad_root
|
||||
|
||||
@@ -514,11 +514,7 @@ perform active triggers on ticket
|
||||
next if ticket_count.zero?
|
||||
next if tickets.take.id != ticket.id
|
||||
|
||||
if recursive == false && local_options[:loop_count] > 1
|
||||
message = "Do not execute recursive triggers per default until Zammad 3.0. With Zammad 3.0 and higher the following trigger is executed '#{trigger.name}' on Ticket:#{ticket.id}. Please review your current triggers and change them if needed."
|
||||
logger.info { message }
|
||||
return [true, message]
|
||||
end
|
||||
return [true, message] if recursive == false && local_options[:loop_count] > 1
|
||||
|
||||
if article && send_notification == false && trigger.perform['notification.email'] && trigger.perform['notification.email']['recipient']
|
||||
recipient = trigger.perform['notification.email']['recipient']
|
||||
|
||||
@@ -140,15 +140,20 @@ class Transaction::Notification
|
||||
end
|
||||
end
|
||||
|
||||
def recipient_myself?(user)
|
||||
return false if @params[:interface_handle] != 'application_server'
|
||||
return true if article&.updated_by_id == user.id
|
||||
return true if !article && @item[:user_id] == user.id
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def send_to_single_recipient(recipient_settings)
|
||||
user = recipient_settings[:user]
|
||||
channels = recipient_settings[:channels]
|
||||
|
||||
# ignore user who changed it by him self via web
|
||||
if @params[:interface_handle] == 'application_server'
|
||||
return if article&.updated_by_id == user.id
|
||||
return if !article && @item[:user_id] == user.id
|
||||
end
|
||||
return if recipient_myself?(user)
|
||||
|
||||
# ignore inactive users
|
||||
return if !user.active?
|
||||
|
||||
@@ -367,7 +367,7 @@ returns
|
||||
# try second lookup with email
|
||||
user ||= User.find_by(email: username.downcase.strip, active: true)
|
||||
|
||||
return if !user || !user.email
|
||||
return if !user || user.email.blank?
|
||||
|
||||
# Discard any possible previous tokens for safety reasons.
|
||||
Token.where(action: 'PasswordReset', user_id: user.id).destroy_all
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class Controllers::GroupsControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
permit! %i[index show], to: ['ticket.agent', 'admin.group', 'ticket.customer']
|
||||
default_permit!('admin.group')
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class Controllers::RolesControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
permit! %i[index show], to: ['ticket.agent', 'admin.role', 'ticket.customer']
|
||||
default_permit!('admin.role')
|
||||
end
|
||||
|
||||
@@ -27,6 +27,6 @@ class Controllers::TicketSharedDraftZoomControllerPolicy < Controllers::Applicat
|
||||
ticket_id = record.params[:ticket_id]
|
||||
ticket = Ticket.find ticket_id
|
||||
|
||||
TicketPolicy.new(user, ticket).update?
|
||||
TicketPolicy.new(user, ticket).agent_update_access?
|
||||
end
|
||||
end
|
||||
|
||||
17
app/policies/group_policy/scope.rb
Normal file
17
app/policies/group_policy/scope.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class GroupPolicy < ApplicationPolicy
|
||||
class Scope < ApplicationPolicy::Scope
|
||||
|
||||
def resolve
|
||||
return scope.all if Setting.get('customer_ticket_create_group_ids').blank?
|
||||
return scope.all if user.permissions?(['ticket.agent', 'admin.group'])
|
||||
|
||||
allowed_group_ids = Auth::RequestCache.fetch_value("GroupPolicy/Scope/allowed_group_ids/#{user.id}") do
|
||||
Array.wrap(Setting.get('customer_ticket_create_group_ids')).map(&:to_i) | TicketPolicy::ReadScope.new(user).resolve.distinct(:group_id).pluck(:group_id)
|
||||
end
|
||||
|
||||
scope.where(id: allowed_group_ids)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,6 +16,6 @@ class Ticket::SharedDraftZoomPolicy < ApplicationPolicy
|
||||
private
|
||||
|
||||
def access?(_method)
|
||||
TicketPolicy.new(user, record.ticket).update?
|
||||
TicketPolicy.new(user, record.ticket).agent_update_access?
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user