Port more changes from updated branch
This commit is contained in:
parent
1c7755f455
commit
bf46bb5beb
10 changed files with 721 additions and 13 deletions
|
|
@ -42,6 +42,10 @@ RUN pnpm install --frozen-lockfile
|
||||||
# This extracts addon files including CoffeeScript, Vue components, TypeScript, and CSS
|
# This extracts addon files including CoffeeScript, Vue components, TypeScript, and CSS
|
||||||
RUN ruby contrib/link/install.rb
|
RUN ruby contrib/link/install.rb
|
||||||
|
|
||||||
|
# Rebuild Vite frontend to include addon Vue components
|
||||||
|
# The base image has pre-built Vite assets, but addon Vue files need to be compiled
|
||||||
|
RUN RAILS_ENV=production bundle exec vite build --clobber
|
||||||
|
|
||||||
# Fix OpenSearch compatibility: Replace 'flattened' with 'flat_object'
|
# Fix OpenSearch compatibility: Replace 'flattened' with 'flat_object'
|
||||||
# Elasticsearch uses 'flattened' but OpenSearch uses 'flat_object'
|
# Elasticsearch uses 'flattened' but OpenSearch uses 'flat_object'
|
||||||
# Without this fix, search index creation fails with:
|
# Without this fix, search index creation fails with:
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,47 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
# OpenSearchController provides access to OpenSearch Dashboards URL and status.
|
# Copyright (C) 2025 Center for Digital Resilience
|
||||||
# This allows the frontend to link directly to OpenSearch Dashboards for analytics.
|
# License: AGPL-3.0-or-later
|
||||||
|
|
||||||
class OpensearchController < ApplicationController
|
class OpensearchController < ApplicationController
|
||||||
prepend_before_action -> { authentication_check && authorize! }
|
prepend_before_action -> { authentication_check && authorize! }
|
||||||
|
|
||||||
# GET /api/v1/opensearch
|
# GET /opensearch
|
||||||
|
# Render the OpenSearch Dashboards iframe page
|
||||||
def index
|
def index
|
||||||
enabled = Setting.get('opensearch_dashboards_enabled') == true
|
# Get the OpenSearch Dashboards URL from environment or use default
|
||||||
url = Setting.get('opensearch_dashboards_url')
|
@opensearch_url = ENV.fetch('OPENSEARCH_DASHBOARDS_URL', 'http://opensearch-dashboards:5601')
|
||||||
|
@default_dashboard = ENV.fetch('OPENSEARCH_DEFAULT_DASHBOARD_URL', '/app/dashboards')
|
||||||
|
|
||||||
render json: {
|
render 'opensearch/index', layout: false
|
||||||
enabled: enabled,
|
end
|
||||||
url: enabled ? url : nil
|
|
||||||
}
|
# GET /opensearch/dashboards/*path
|
||||||
|
# Proxy requests to OpenSearch Dashboards
|
||||||
|
# This allows embedding OpenSearch without CORS issues
|
||||||
|
def proxy
|
||||||
|
path = params[:path] || ''
|
||||||
|
opensearch_url = ENV.fetch('OPENSEARCH_DASHBOARDS_URL', 'http://opensearch-dashboards:5601')
|
||||||
|
target_url = "#{opensearch_url}/#{path}"
|
||||||
|
|
||||||
|
# Add query parameters if present
|
||||||
|
if request.query_string.present?
|
||||||
|
target_url = "#{target_url}?#{request.query_string}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Forward the request to OpenSearch Dashboards
|
||||||
|
response = HTTParty.get(
|
||||||
|
target_url,
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-user' => current_user.email,
|
||||||
|
'x-forwarded-roles' => current_user.role_ids.map { |id| Role.find(id).name }.join(','),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return the response
|
||||||
|
render plain: response.body, status: response.code, content_type: response.headers['content-type']
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "OpenSearch proxy error: #{e.message}"
|
||||||
|
render plain: 'Error connecting to OpenSearch Dashboards', status: :service_unavailable
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
|
||||||
|
<!-- CDR Link Extension: Adds Signal notification channel support -->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cloneDeep } from 'lodash-es'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { toRef, computed } from 'vue'
|
||||||
|
|
||||||
|
import useValue from '#shared/components/Form/composables/useValue.ts'
|
||||||
|
import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
|
||||||
|
import { useApplicationStore } from '#shared/stores/application.ts'
|
||||||
|
|
||||||
|
import CommonSimpleTable from '#desktop/components/CommonTable/CommonSimpleTable.vue'
|
||||||
|
import type { TableSimpleHeader } from '#desktop/components/CommonTable/types.ts'
|
||||||
|
|
||||||
|
import {
|
||||||
|
NotificationMatrixColumnKey,
|
||||||
|
NotificationMatrixPathKey,
|
||||||
|
NotificationMatrixRowKey,
|
||||||
|
} from './types.ts'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
context: FormFieldContext
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const context = toRef(props, 'context')
|
||||||
|
|
||||||
|
const { localValue } = useValue(context)
|
||||||
|
|
||||||
|
// Get application config to check if Signal notifications are enabled
|
||||||
|
const { config } = storeToRefs(useApplicationStore())
|
||||||
|
const signalNotificationEnabled = computed(() =>
|
||||||
|
config.value?.signal_notification_enabled === true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Base headers without Signal
|
||||||
|
const baseTableHeaders: TableSimpleHeader[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: __('Name'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: NotificationMatrixColumnKey.MyTickets,
|
||||||
|
path: NotificationMatrixPathKey.Criteria,
|
||||||
|
label: __('My tickets'),
|
||||||
|
alignContent: 'center',
|
||||||
|
headerClass: 'w-20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: NotificationMatrixColumnKey.NotAssigned,
|
||||||
|
path: NotificationMatrixPathKey.Criteria,
|
||||||
|
label: __('Not assigned'),
|
||||||
|
alignContent: 'center',
|
||||||
|
headerClass: 'w-20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: NotificationMatrixColumnKey.SubscribedTickets,
|
||||||
|
path: NotificationMatrixPathKey.Criteria,
|
||||||
|
label: __('Subscribed tickets'),
|
||||||
|
alignContent: 'center',
|
||||||
|
headerClass: 'w-20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: NotificationMatrixColumnKey.AllTickets,
|
||||||
|
path: NotificationMatrixPathKey.Criteria,
|
||||||
|
label: __('All tickets'),
|
||||||
|
alignContent: 'center',
|
||||||
|
headerClass: 'w-20',
|
||||||
|
columnSeparator: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: NotificationMatrixColumnKey.AlsoNotifyViaEmail,
|
||||||
|
path: NotificationMatrixPathKey.Channel,
|
||||||
|
label: __('Also notify via email'),
|
||||||
|
alignContent: 'center',
|
||||||
|
headerClass: 'w-20',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Signal header to be conditionally added
|
||||||
|
const signalHeader: TableSimpleHeader = {
|
||||||
|
key: 'signal' as NotificationMatrixColumnKey,
|
||||||
|
path: NotificationMatrixPathKey.Channel,
|
||||||
|
label: __('Also notify via Signal'),
|
||||||
|
alignContent: 'center',
|
||||||
|
headerClass: 'w-20',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed headers that include Signal when enabled
|
||||||
|
const tableHeaders = computed(() => {
|
||||||
|
if (signalNotificationEnabled.value) {
|
||||||
|
return [...baseTableHeaders, signalHeader]
|
||||||
|
}
|
||||||
|
return baseTableHeaders
|
||||||
|
})
|
||||||
|
|
||||||
|
// All column keys including Signal for the template
|
||||||
|
const allColumnKeys = computed(() => {
|
||||||
|
const keys = Object.values(NotificationMatrixColumnKey)
|
||||||
|
if (signalNotificationEnabled.value) {
|
||||||
|
keys.push('signal' as NotificationMatrixColumnKey)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
})
|
||||||
|
|
||||||
|
const tableItems = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
key: NotificationMatrixRowKey.Create,
|
||||||
|
name: __('New ticket'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
key: NotificationMatrixRowKey.Update,
|
||||||
|
name: __('Ticket update'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
key: NotificationMatrixRowKey.ReminderReached,
|
||||||
|
name: __('Ticket reminder reached'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
key: NotificationMatrixRowKey.Escalation,
|
||||||
|
name: __('Ticket escalation'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const valueLookup = (
|
||||||
|
rowKey: NotificationMatrixRowKey,
|
||||||
|
pathKey: NotificationMatrixPathKey,
|
||||||
|
columnKey: NotificationMatrixColumnKey | string,
|
||||||
|
) => {
|
||||||
|
const row = localValue.value?.[rowKey]
|
||||||
|
if (!row) return undefined
|
||||||
|
|
||||||
|
return row[pathKey]?.[columnKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateValue = (
|
||||||
|
rowKey: NotificationMatrixRowKey,
|
||||||
|
pathKey: NotificationMatrixPathKey,
|
||||||
|
columnKey: NotificationMatrixColumnKey | string,
|
||||||
|
state: boolean | undefined,
|
||||||
|
) => {
|
||||||
|
const values = cloneDeep(localValue.value) || {}
|
||||||
|
|
||||||
|
values[rowKey] = values[rowKey] || {}
|
||||||
|
values[rowKey][pathKey] = values[rowKey][pathKey] || {}
|
||||||
|
values[rowKey][pathKey][columnKey] = Boolean(state)
|
||||||
|
|
||||||
|
localValue.value = values
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<output
|
||||||
|
:id="context.id"
|
||||||
|
:class="context.classes.input"
|
||||||
|
:name="context.node.name"
|
||||||
|
:aria-disabled="context.disabled"
|
||||||
|
:aria-describedby="context.describedBy"
|
||||||
|
v-bind="context.attrs"
|
||||||
|
>
|
||||||
|
<CommonSimpleTable
|
||||||
|
:caption="__('Notifications matrix')"
|
||||||
|
class="mb-4 w-full"
|
||||||
|
:headers="tableHeaders"
|
||||||
|
:items="tableItems"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="key in allColumnKeys"
|
||||||
|
:key="key"
|
||||||
|
#[`column-cell-${key}`]="{ item, header }"
|
||||||
|
>
|
||||||
|
<FormKit
|
||||||
|
:id="`notifications_${item.key}_${header.path}_${header.key}`"
|
||||||
|
:model-value="
|
||||||
|
valueLookup(
|
||||||
|
item.key as NotificationMatrixRowKey,
|
||||||
|
header.path as NotificationMatrixPathKey,
|
||||||
|
header.key as NotificationMatrixColumnKey,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
type="checkbox"
|
||||||
|
:name="`notifications_${item.key}_${header.path}_${header.key}`"
|
||||||
|
:disabled="context.disabled"
|
||||||
|
:ignore="true"
|
||||||
|
:label-sr-only="true"
|
||||||
|
:label="`${i18n.t(item.name as string)} - ${i18n.t(header.label)}`"
|
||||||
|
@update:model-value="
|
||||||
|
updateValue(
|
||||||
|
item.key as NotificationMatrixRowKey,
|
||||||
|
header.path as NotificationMatrixPathKey,
|
||||||
|
header.key as NotificationMatrixColumnKey,
|
||||||
|
$event,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@blur="context.handlers.blur"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CommonSimpleTable>
|
||||||
|
</output>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
|
||||||
|
// CDR Link Extension: Adds Signal notification channel support
|
||||||
|
|
||||||
|
export enum NotificationMatrixRowKey {
|
||||||
|
Create = 'create',
|
||||||
|
Escalation = 'escalation',
|
||||||
|
ReminderReached = 'reminderReached',
|
||||||
|
Update = 'update',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NotificationMatrixPathKey {
|
||||||
|
Criteria = 'criteria',
|
||||||
|
Channel = 'channel',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NotificationMatrixColumnKey {
|
||||||
|
MyTickets = 'ownedByMe',
|
||||||
|
NotAssigned = 'ownedByNobody',
|
||||||
|
SubscribedTickets = 'subscribed',
|
||||||
|
AllTickets = 'no',
|
||||||
|
AlsoNotifyViaEmail = 'email',
|
||||||
|
// CDR Link: Signal notification channel
|
||||||
|
AlsoNotifyViaSignal = 'signal',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationMatrix = {
|
||||||
|
[rowKey in NotificationMatrixRowKey]: {
|
||||||
|
[pathKey in NotificationMatrixPathKey]: {
|
||||||
|
[columnKey in NotificationMatrixColumnKey]: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
|
||||||
|
<!-- CDR Link Extension: Adds Signal notification phone number field -->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { isEqual } from 'lodash-es'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
NotificationTypes,
|
||||||
|
useNotifications,
|
||||||
|
} from '#shared/components/CommonNotifications/index.ts'
|
||||||
|
import Form from '#shared/components/Form/Form.vue'
|
||||||
|
import { type FormSubmitData } from '#shared/components/Form/types.ts'
|
||||||
|
import { useForm } from '#shared/components/Form/useForm.ts'
|
||||||
|
import { useConfirmation } from '#shared/composables/useConfirmation.ts'
|
||||||
|
import { defineFormSchema } from '#shared/form/defineFormSchema.ts'
|
||||||
|
import {
|
||||||
|
EnumFormUpdaterId,
|
||||||
|
EnumNotificationSoundFile,
|
||||||
|
type UserNotificationMatrixInput,
|
||||||
|
} from '#shared/graphql/types.ts'
|
||||||
|
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
|
||||||
|
import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
|
||||||
|
import { useSessionStore } from '#shared/stores/session.ts'
|
||||||
|
import { useApplicationStore } from '#shared/stores/application.ts'
|
||||||
|
import type { UserData } from '#shared/types/store.ts'
|
||||||
|
|
||||||
|
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
|
||||||
|
import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
|
||||||
|
import { useBreadcrumb } from '#desktop/pages/personal-setting/composables/useBreadcrumb.ts'
|
||||||
|
import { useUserCurrentNotificationPreferencesResetMutation } from '#desktop/pages/personal-setting/graphql/mutations/userCurrentNotificationPreferencesReset.api.ts'
|
||||||
|
import { useUserCurrentNotificationPreferencesUpdateMutation } from '#desktop/pages/personal-setting/graphql/mutations/userCurrentNotificationPreferencesUpdate.api.ts'
|
||||||
|
import type { NotificationFormData } from '#desktop/pages/personal-setting/types/notifications.ts'
|
||||||
|
|
||||||
|
// CDR Link: Extended form data type to include signal_uid
|
||||||
|
interface ExtendedNotificationFormData extends NotificationFormData {
|
||||||
|
signal_uid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { breadcrumbItems } = useBreadcrumb(__('Notifications'))
|
||||||
|
|
||||||
|
const { user } = storeToRefs(useSessionStore())
|
||||||
|
|
||||||
|
// CDR Link: Get application config for signal notification setting
|
||||||
|
const { config } = storeToRefs(useApplicationStore())
|
||||||
|
const signalNotificationEnabled = computed(() =>
|
||||||
|
config.value?.signal_notification_enabled === true
|
||||||
|
)
|
||||||
|
|
||||||
|
const { notify } = useNotifications()
|
||||||
|
|
||||||
|
const { waitForConfirmation } = useConfirmation()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const { form, onChangedField, formReset, values, isDirty } = useForm()
|
||||||
|
|
||||||
|
const soundOptions = Object.keys(EnumNotificationSoundFile).map((sound) => ({
|
||||||
|
label: sound,
|
||||||
|
value: sound,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// CDR Link: Check if any Signal notification checkbox is selected in the matrix
|
||||||
|
const hasSignalNotificationSelected = computed(() => {
|
||||||
|
const matrix = values.value?.matrix
|
||||||
|
if (!matrix) return false
|
||||||
|
|
||||||
|
// Check all rows (create, update, reminderReached, escalation) for signal channel enabled
|
||||||
|
const rows = ['create', 'update', 'reminderReached', 'escalation']
|
||||||
|
return rows.some((row) => matrix[row]?.channel?.signal === true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// CDR Link: Dynamically build schema based on signal notification setting
|
||||||
|
const schema = computed(() => {
|
||||||
|
const baseSchema = [
|
||||||
|
{
|
||||||
|
type: 'notifications',
|
||||||
|
name: 'matrix',
|
||||||
|
label: __('Notification matrix'),
|
||||||
|
labelSrOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'group_ids',
|
||||||
|
label: __('Limit notifications to specific groups'),
|
||||||
|
help: __('Affects only notifications for not assigned and all tickets.'),
|
||||||
|
props: {
|
||||||
|
clearable: true,
|
||||||
|
multiple: true,
|
||||||
|
noOptionsLabelTranslation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// CDR Link: Add Signal phone number field only when Signal notifications are enabled
|
||||||
|
// AND at least one Signal notification checkbox is selected
|
||||||
|
if (signalNotificationEnabled.value && hasSignalNotificationSelected.value) {
|
||||||
|
baseSchema.push({
|
||||||
|
type: 'text',
|
||||||
|
name: 'signal_uid',
|
||||||
|
label: __('Signal Phone Number'),
|
||||||
|
help: __('Use international format with country code (e.g., +1234567890). Required when Signal notifications are enabled in the matrix.'),
|
||||||
|
props: {
|
||||||
|
placeholder: '+1234567890',
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sound settings
|
||||||
|
baseSchema.push(
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'file',
|
||||||
|
label: __('Notification sound'),
|
||||||
|
props: {
|
||||||
|
options: soundOptions,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
type: 'toggle',
|
||||||
|
name: 'enabled',
|
||||||
|
label: __('Play user interface sound effects'),
|
||||||
|
props: {
|
||||||
|
variants: { true: 'True', false: 'False' },
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
return defineFormSchema(baseSchema)
|
||||||
|
})
|
||||||
|
|
||||||
|
const initialFormValues = computed<ExtendedNotificationFormData>((oldValues) => {
|
||||||
|
const { notificationConfig = {}, notificationSound = {} } =
|
||||||
|
user.value?.personalSettings || {}
|
||||||
|
|
||||||
|
const values: ExtendedNotificationFormData = {
|
||||||
|
group_ids: notificationConfig?.groupIds ?? [],
|
||||||
|
matrix: notificationConfig?.matrix || {},
|
||||||
|
// CDR Link: Include signal_uid from notification config
|
||||||
|
signal_uid: notificationConfig?.signalUid ?? '',
|
||||||
|
|
||||||
|
// Default notification sound settings are not present on the user preferences.
|
||||||
|
file: notificationSound?.file ?? EnumNotificationSoundFile.Xylo,
|
||||||
|
enabled: notificationSound?.enabled ?? true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldValues && isEqual(values, oldValues)) return oldValues
|
||||||
|
|
||||||
|
return values
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(initialFormValues, (newValues) => {
|
||||||
|
// No reset needed when the form has already the correct state.
|
||||||
|
if (isEqual(values.value, newValues) && !isDirty.value) return
|
||||||
|
|
||||||
|
formReset({ values: newValues })
|
||||||
|
})
|
||||||
|
|
||||||
|
onChangedField('file', (fileName) => {
|
||||||
|
new Audio(`/assets/sounds/${fileName?.toString()}.mp3`)?.play()
|
||||||
|
})
|
||||||
|
|
||||||
|
// CDR Link: Save signal_uid via REST API
|
||||||
|
const saveSignalUid = async (signalUid: string | undefined) => {
|
||||||
|
if (!signalNotificationEnabled.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/users/preferences', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
notification_config: {
|
||||||
|
signal_uid: signalUid || '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to save Signal phone number')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving signal_uid:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (form: FormSubmitData<ExtendedNotificationFormData>) => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
const notificationUpdateMutation = new MutationHandler(
|
||||||
|
useUserCurrentNotificationPreferencesUpdateMutation(),
|
||||||
|
{
|
||||||
|
errorNotificationMessage: __('Notification settings could not be saved.'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// CDR Link: Save signal_uid separately via REST API
|
||||||
|
if (signalNotificationEnabled.value) {
|
||||||
|
await saveSignalUid(form.signal_uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save other settings via GraphQL mutation
|
||||||
|
const response = await notificationUpdateMutation.send({
|
||||||
|
matrix: form.matrix as UserNotificationMatrixInput,
|
||||||
|
groupIds:
|
||||||
|
form?.group_ids?.map((id) => convertToGraphQLId('Group', id)) || [],
|
||||||
|
sound: {
|
||||||
|
file: form.file as EnumNotificationSoundFile,
|
||||||
|
enabled: form.enabled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response?.userCurrentNotificationPreferencesUpdate) {
|
||||||
|
notify({
|
||||||
|
id: 'notification-update-success',
|
||||||
|
type: NotificationTypes.Success,
|
||||||
|
message: __('Notification settings have been saved successfully.'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFormToDefaults = (
|
||||||
|
personalSettings: UserData['personalSettings'],
|
||||||
|
) => {
|
||||||
|
const resetValues: any = {
|
||||||
|
matrix: personalSettings?.notificationConfig?.matrix || {},
|
||||||
|
}
|
||||||
|
// CDR Link: Reset signal_uid as well
|
||||||
|
if (signalNotificationEnabled.value) {
|
||||||
|
resetValues.signal_uid = ''
|
||||||
|
}
|
||||||
|
form.value?.resetForm({
|
||||||
|
values: resetValues,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onResetToDefaultSettings = async () => {
|
||||||
|
const confirmed = await waitForConfirmation(
|
||||||
|
__('Are you sure? Your notifications settings will be reset to default.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
const notificationResetMutation = new MutationHandler(
|
||||||
|
useUserCurrentNotificationPreferencesResetMutation(),
|
||||||
|
{
|
||||||
|
errorNotificationMessage: __('Notification settings could not be reset.'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// CDR Link: Reset signal_uid via REST API
|
||||||
|
if (signalNotificationEnabled.value) {
|
||||||
|
await saveSignalUid('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await notificationResetMutation.send()
|
||||||
|
const personalSettings =
|
||||||
|
response?.userCurrentNotificationPreferencesReset?.user
|
||||||
|
?.personalSettings
|
||||||
|
|
||||||
|
if (!personalSettings) return
|
||||||
|
|
||||||
|
resetFormToDefaults(personalSettings)
|
||||||
|
|
||||||
|
notify({
|
||||||
|
id: 'notification-reset-success',
|
||||||
|
type: NotificationTypes.Success,
|
||||||
|
message: __('Notification settings have been reset to default.'),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LayoutContent :breadcrumb-items="breadcrumbItems" width="narrow">
|
||||||
|
<div class="mb-4">
|
||||||
|
<Form
|
||||||
|
id="notifications-form"
|
||||||
|
ref="form"
|
||||||
|
:schema="schema"
|
||||||
|
:form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterUserNotifications"
|
||||||
|
form-updater-initial-only
|
||||||
|
:initial-values="initialFormValues"
|
||||||
|
@submit="onSubmit($event as FormSubmitData<ExtendedNotificationFormData>)"
|
||||||
|
>
|
||||||
|
<template #after-fields>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<CommonButton
|
||||||
|
size="medium"
|
||||||
|
variant="danger"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="onResetToDefaultSettings"
|
||||||
|
>
|
||||||
|
{{ $t('Reset to Default Settings') }}
|
||||||
|
</CommonButton>
|
||||||
|
<CommonButton
|
||||||
|
size="medium"
|
||||||
|
type="submit"
|
||||||
|
variant="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
{{ $t('Save Notifications') }}
|
||||||
|
</CommonButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</LayoutContent>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenSearch Dashboards - Zammad</title>
|
||||||
|
<style>
|
||||||
|
body, html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#opensearch-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<iframe
|
||||||
|
id="opensearch-iframe"
|
||||||
|
src="<%= @opensearch_url %><%= @default_dashboard %>"
|
||||||
|
allow="fullscreen"
|
||||||
|
></iframe>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# OpenSearch Compatibility Fix
|
||||||
|
#
|
||||||
|
# This initializer patches SearchIndexBackend to use 'flat_object' instead of 'flattened'
|
||||||
|
# for field type mapping. OpenSearch uses 'flat_object' while Elasticsearch uses 'flattened'.
|
||||||
|
#
|
||||||
|
# Without this fix, search index creation fails with:
|
||||||
|
# [o.o.c.m.MetadataCreateIndexService] failed on parsing mappings on index creation
|
||||||
|
# org.opensearch.index.mapper.MapperParsingException: Failed to parse mapping [_doc]:
|
||||||
|
# No handler for type [flattened] declared on field [preferences]
|
||||||
|
#
|
||||||
|
# See: https://github.com/zammad/zammad/blob/develop/lib/search_index_backend.rb
|
||||||
|
|
||||||
|
module SearchIndexBackendOpenSearchPatch
|
||||||
|
# Override the _mapping_item_type_es method to use flat_object instead of flattened
|
||||||
|
def _mapping_item_type_es(item_type)
|
||||||
|
# Call the original method
|
||||||
|
result = super(item_type)
|
||||||
|
|
||||||
|
# Replace 'flattened' with 'flat_object' for OpenSearch compatibility
|
||||||
|
if result == 'flattened'
|
||||||
|
return 'flat_object'
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply the patch after Rails has finished loading all classes
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
SearchIndexBackend.singleton_class.prepend(SearchIndexBackendOpenSearchPatch)
|
||||||
|
Rails.logger.info 'OpenSearch compatibility patch applied successfully'
|
||||||
|
end
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
Zammad::Application.routes.draw do
|
# Copyright (C) 2025 Center for Digital Resilience
|
||||||
scope Rails.configuration.api_path do
|
# License: AGPL-3.0-or-later
|
||||||
match '/opensearch', to: 'opensearch#index', via: :get
|
|
||||||
end
|
Rails.application.routes.draw do
|
||||||
|
# OpenSearch Dashboards integration
|
||||||
|
get '/opensearch', to: 'opensearch#index'
|
||||||
|
get '/opensearch/*path', to: 'opensearch#proxy'
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Copyright (C) 2025 Center for Digital Resilience
|
||||||
|
# License: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
class AddOpensearchNavigation < ActiveRecord::Migration[5.2]
|
||||||
|
def self.up
|
||||||
|
# Add permission for accessing OpenSearch dashboards
|
||||||
|
Permission.create_if_not_exists(
|
||||||
|
name: 'user_preferences.opensearch',
|
||||||
|
note: 'Access to OpenSearch Dashboards',
|
||||||
|
preferences: {}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a navigation entry for OpenSearch
|
||||||
|
# This will add a menu item in the sidebar
|
||||||
|
NavBar.create_if_not_exists(
|
||||||
|
name: 'OpenSearch',
|
||||||
|
link: '/opensearch',
|
||||||
|
prio: 2600, # Position in menu (after Dashboard=1400, Tickets=2000, Stats=2500)
|
||||||
|
permission: ['user_preferences.opensearch'],
|
||||||
|
parent: '',
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Grant permission to admin and agent roles
|
||||||
|
%w[Admin Agent].each do |role_name|
|
||||||
|
role = Role.find_by(name: role_name)
|
||||||
|
next if !role
|
||||||
|
|
||||||
|
role.permission_grant('user_preferences.opensearch')
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info 'OpenSearch navigation menu item created successfully'
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
# Remove navigation entry
|
||||||
|
navbar = NavBar.find_by(name: 'OpenSearch')
|
||||||
|
navbar&.destroy
|
||||||
|
|
||||||
|
# Remove permission
|
||||||
|
permission = Permission.find_by(name: 'user_preferences.opensearch')
|
||||||
|
permission&.destroy
|
||||||
|
|
||||||
|
Rails.logger.info 'OpenSearch navigation menu item removed'
|
||||||
|
end
|
||||||
|
end
|
||||||
8
packages/zammad-addon-link/src/public/assets/javascripts/qrcode.min.js
vendored
Normal file
8
packages/zammad-addon-link/src/public/assets/javascripts/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue