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
|
||||
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'
|
||||
# Elasticsearch uses 'flattened' but OpenSearch uses 'flat_object'
|
||||
# Without this fix, search index creation fails with:
|
||||
|
|
|
|||
|
|
@ -1,19 +1,47 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# OpenSearchController provides access to OpenSearch Dashboards URL and status.
|
||||
# This allows the frontend to link directly to OpenSearch Dashboards for analytics.
|
||||
# Copyright (C) 2025 Center for Digital Resilience
|
||||
# License: AGPL-3.0-or-later
|
||||
|
||||
class OpensearchController < ApplicationController
|
||||
prepend_before_action -> { authentication_check && authorize! }
|
||||
|
||||
# GET /api/v1/opensearch
|
||||
# GET /opensearch
|
||||
# Render the OpenSearch Dashboards iframe page
|
||||
def index
|
||||
enabled = Setting.get('opensearch_dashboards_enabled') == true
|
||||
url = Setting.get('opensearch_dashboards_url')
|
||||
# Get the OpenSearch Dashboards URL from environment or use default
|
||||
@opensearch_url = ENV.fetch('OPENSEARCH_DASHBOARDS_URL', 'http://opensearch-dashboards:5601')
|
||||
@default_dashboard = ENV.fetch('OPENSEARCH_DEFAULT_DASHBOARD_URL', '/app/dashboards')
|
||||
|
||||
render json: {
|
||||
enabled: enabled,
|
||||
url: enabled ? url : nil
|
||||
render 'opensearch/index', layout: false
|
||||
end
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
Zammad::Application.routes.draw do
|
||||
scope Rails.configuration.api_path do
|
||||
match '/opensearch', to: 'opensearch#index', via: :get
|
||||
end
|
||||
# Copyright (C) 2025 Center for Digital Resilience
|
||||
# License: AGPL-3.0-or-later
|
||||
|
||||
Rails.application.routes.draw do
|
||||
# OpenSearch Dashboards integration
|
||||
get '/opensearch', to: 'opensearch#index'
|
||||
get '/opensearch/*path', to: 'opensearch#proxy'
|
||||
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