Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b0f67a43a | |||
| 96fe631e61 | |||
| 35a3d40c6a | |||
| 51185b548f | |||
| 4d534c724d | |||
| 333966416b | |||
| 8f3d3cea8b | |||
| 44664426f5 | |||
| 37721cd061 | |||
| ef6052006e | |||
| 19b3b33d70 | |||
| 0a374ac8d5 | |||
| 32e76ecc75 | |||
| d89d0f95f6 | |||
| 3c4fe24056 | |||
| abfdbbcaa2 | |||
| 6e03ff7560 | |||
| 8cc8cadda2 | |||
| 10ef685ab4 | |||
| 79eaebf8a1 | |||
| 50beb170e4 | |||
| 9418e56dfe | |||
| 157a8a9453 | |||
| 3277ca18c9 | |||
| 4c815e7e81 | |||
| 60a541fec1 | |||
| 2702aea14a | |||
| 32cd96c92b | |||
| da7f4578d2 | |||
| db9c68dc5f | |||
| e513c757b9 | |||
| ce15178dfd | |||
| 377076e60f | |||
| ed399998d4 | |||
| 50356f8b05 | |||
| 65ffa835d9 | |||
| a91d78beff | |||
| 561ea3691a | |||
| 1fe7c77fbf | |||
| 3c94ffeff3 | |||
| 9067dc62f7 | |||
| 36bfe59115 | |||
| 5b1fe5f806 | |||
| 5855d03ae1 | |||
| 6090682afd | |||
| ae6719049d | |||
| 98694e46d6 | |||
| 0a6a4d581c | |||
| d7efb61207 | |||
| 1e081139e6 | |||
| 0f81e227fc | |||
| 57b48520af | |||
| bda9ec1192 | |||
| e9af9dc268 | |||
| d595f23310 | |||
| 0b95419eb6 | |||
| 8a89bc1296 | |||
| f659c73ffa | |||
| 8e3cd85e3d | |||
| 865b769a71 | |||
| 10c2c4bbc7 | |||
| b8cd65c45c | |||
| f86d598610 | |||
| 06c618dd50 | |||
| 56fc3dc065 | |||
| 8fa87ef1d7 | |||
| f1cee7268d | |||
| 7c9c81b2a4 | |||
| c79a76c9d7 | |||
| 08d6140f2a | |||
| 69127e5749 | |||
| f0cf2122f4 | |||
| d8712c1247 | |||
| f4b1059f95 | |||
| 1d3ea606c5 | |||
| 9d3ec28504 | |||
| 1c7452f360 | |||
| 46cfd53052 | |||
| 0456f467c7 | |||
| aa9f18525e | |||
| 4ccb916895 | |||
| fe74ea89a5 | |||
| da78796cc1 |
@@ -38,7 +38,7 @@ Joomla **package** (`pkg_mokosuiteclient`) with 17 sub-extensions:
|
||||
|
||||
### Component (`com_mokosuiteclient`)
|
||||
- Admin dashboard with plugin management, WAF charts, extension catalog
|
||||
- Helpdesk ticketing system
|
||||
- Content tools: snippets, templates, replacements, conditions, articles anywhere, users anywhere
|
||||
- REST API controllers
|
||||
|
||||
### Modules
|
||||
@@ -50,7 +50,6 @@ Joomla **package** (`pkg_mokosuiteclient`) with 17 sub-extensions:
|
||||
### Task Plugins
|
||||
- `plg_task_mokosuiteclientdemo` — scheduled demo site reset
|
||||
- `plg_task_mokosuiteclientsync` — scheduled content sync
|
||||
- `plg_task_mokosuiteclient_tickets` — ticket automation
|
||||
|
||||
### Update Server
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Deploy
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
||||
# VERSION: 04.07.00
|
||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
||||
|
||||
name: "Universal: Deploy to Dev (Manual)"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
clear_remote:
|
||||
description: 'Delete all remote files before uploading'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: SFTP Deploy to Dev
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Check FTP configuration
|
||||
id: check
|
||||
env:
|
||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
run: |
|
||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
REMOTE="${PATH_VAR%/}"
|
||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
[ -z "$PORT" ] && PORT="22"
|
||||
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy via SFTP
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
||||
> /tmp/sftp-config.json
|
||||
|
||||
if [ -n "$SFTP_KEY" ]; then
|
||||
echo "$SFTP_KEY" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||
else
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
fi
|
||||
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 02.47.27
|
||||
# VERSION: 02.47.81
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
|
||||
name: "Universal: Security Audit"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Composer audit
|
||||
if: hashFiles('composer.lock') != ''
|
||||
run: |
|
||||
echo "=== Composer Security Audit ==="
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
||||
fi
|
||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
||||
RESULT=$?
|
||||
if [ $RESULT -ne 0 ]; then
|
||||
echo "::warning::Composer vulnerabilities found"
|
||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "No known vulnerabilities in composer dependencies"
|
||||
fi
|
||||
|
||||
- name: NPM audit
|
||||
if: hashFiles('package-lock.json') != ''
|
||||
run: |
|
||||
echo "=== NPM Security Audit ==="
|
||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
||||
echo "No known vulnerabilities in npm dependencies"
|
||||
else
|
||||
echo "::warning::NPM vulnerabilities found"
|
||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Notify on vulnerabilities
|
||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
curl -sS \
|
||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
||||
-H "Tags: lock,warning" \
|
||||
-H "Priority: high" \
|
||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
PATH: ./CHANGELOG.md
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
BRIEF: Version history using `Keep a Changelog`
|
||||
-->
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||
-->
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand
|
||||
INGROUP: MokoStandards.Governance
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /GOVERNANCE.md
|
||||
BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand
|
||||
-->
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
PATH: ./LICENSE.md
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
BRIEF: Project license (GPL-3.0-or-later)
|
||||
-->
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /README.md
|
||||
BRIEF: MokoSuiteClient platform plugin for Joomla
|
||||
-->
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
||||
INGROUP: [PROJECT_NAME].Documentation
|
||||
REPO: [REPOSITORY_URL]
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
INGROUP: MokoSuiteClient.Build
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
FILE: build-guide.md
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /docs/guides/
|
||||
BRIEF: Build and packaging guide for the MokoSuiteClient system plugin
|
||||
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Build Guide (VERSION: 02.47.27)
|
||||
# MokoSuiteClient Build Guide (VERSION: 02.47.81)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /docs/guides/configuration-guide.md
|
||||
BRIEF: Configuration guide for the MokoSuiteClient system plugin
|
||||
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Configuration Guide (VERSION: 02.47.27)
|
||||
# MokoSuiteClient Configuration Guide (VERSION: 02.47.81)
|
||||
|
||||
## 1. Objective
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /docs/guides/installation-guide.md
|
||||
BRIEF: Installation guide for the MokoSuiteClient system plugin
|
||||
NOTE: First document in the guide set
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Installation Guide (VERSION: 02.47.27)
|
||||
# MokoSuiteClient Installation Guide (VERSION: 02.47.81)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /docs/guides/operations-guide.md
|
||||
BRIEF: Operational guide for administering and managing the MokoSuiteClient system plugin
|
||||
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Operations Guide (VERSION: 02.47.27)
|
||||
# MokoSuiteClient Operations Guide (VERSION: 02.47.81)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /docs/guides/rollback-and-recovery-guide.md
|
||||
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
|
||||
NOTE: Completes the core guide set for Suite plugin governance
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.47.27)
|
||||
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.47.81)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /docs/guides/testing-guide.md
|
||||
BRIEF: Testing guide for MokoSuiteClient v02.01.08
|
||||
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Testing Guide (VERSION: 02.47.27)
|
||||
# MokoSuiteClient Testing Guide (VERSION: 02.47.81)
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /docs/guides/troubleshooting-guide.md
|
||||
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuiteClient plugin
|
||||
NOTE: Designed for administrators and Suite operations teams
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.47.27)
|
||||
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.47.81)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
||||
BRIEF: Guide for updating, versioning, and maintaining the MokoSuiteClient plugin
|
||||
NOTE: Defines release flow, version rules, and upgrade validation
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.47.27)
|
||||
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.47.81)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
+2
-2
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
PATH: /docs/index.md
|
||||
BRIEF: Master index of all documentation for the MokoSuiteClient plugin
|
||||
NOTE: Automatically maintained index for all guide canvases
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Documentation Index (VERSION: 02.47.27)
|
||||
# MokoSuiteClient Documentation Index (VERSION: 02.47.81)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
INGROUP: MokoSuiteClient
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
PATH: /docs/plugin-basic.md
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
BRIEF: Baseline documentation for the MokoSuiteClient system plugin
|
||||
NOTE: Foundational reference for internal and external stakeholders
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Plugin Overview (VERSION: 02.47.27)
|
||||
# MokoSuiteClient Plugin Overview (VERSION: 02.47.81)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ DEFGROUP: MokoSuiteClient.Documentation
|
||||
INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient
|
||||
PATH: /docs/update-server.md
|
||||
VERSION: 02.47.27
|
||||
VERSION: 02.47.81
|
||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||
-->
|
||||
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<access component="com_mokosuiteclient">
|
||||
<section name="component">
|
||||
<!-- Core Joomla ACL -->
|
||||
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
|
||||
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
|
||||
|
||||
<!-- Dashboard & UI -->
|
||||
<action name="mokosuiteclient.dashboard" title="COM_MOKOSUITECLIENT_ACL_DASHBOARD" description="COM_MOKOSUITECLIENT_ACL_DASHBOARD_DESC" />
|
||||
<action name="mokosuiteclient.extensions" title="COM_MOKOSUITECLIENT_ACL_EXTENSIONS" description="COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC" />
|
||||
<action name="mokosuiteclient.htaccess" title="COM_MOKOSUITECLIENT_ACL_HTACCESS" description="COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC" />
|
||||
<action name="mokosuiteclient.tickets" title="COM_MOKOSUITECLIENT_ACL_TICKETS" description="COM_MOKOSUITECLIENT_ACL_TICKETS_DESC" />
|
||||
<action name="mokosuiteclient.tickets.create" title="COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE" description="COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE_DESC" />
|
||||
<action name="mokosuiteclient.tickets.assign" title="COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN" description="COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN_DESC" />
|
||||
<action name="mokosuiteclient.plugins.toggle" title="COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE" description="COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE_DESC" />
|
||||
<action name="mokosuiteclient.cache" title="COM_MOKOSUITECLIENT_ACL_CACHE" description="COM_MOKOSUITECLIENT_ACL_CACHE_DESC" />
|
||||
|
||||
<!-- Server Config -->
|
||||
<action name="mokosuiteclient.htaccess" title="COM_MOKOSUITECLIENT_ACL_HTACCESS" description="COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC" />
|
||||
|
||||
<!-- Security -->
|
||||
<action name="mokosuiteclient.security.waflog" title="COM_MOKOSUITECLIENT_ACL_WAFLOG" description="COM_MOKOSUITECLIENT_ACL_WAFLOG_DESC" />
|
||||
<action name="mokosuiteclient.security.impersonate" title="COM_MOKOSUITECLIENT_ACL_IMPERSONATE" description="COM_MOKOSUITECLIENT_ACL_IMPERSONATE_DESC" />
|
||||
|
||||
<!-- Content Tools -->
|
||||
<action name="mokosuiteclient.snippets.manage" title="COM_MOKOSUITECLIENT_ACL_SNIPPETS" description="COM_MOKOSUITECLIENT_ACL_SNIPPETS_DESC" />
|
||||
<action name="mokosuiteclient.templates.manage" title="COM_MOKOSUITECLIENT_ACL_TEMPLATES" description="COM_MOKOSUITECLIENT_ACL_TEMPLATES_DESC" />
|
||||
<action name="mokosuiteclient.replacements.manage" title="COM_MOKOSUITECLIENT_ACL_REPLACEMENTS" description="COM_MOKOSUITECLIENT_ACL_REPLACEMENTS_DESC" />
|
||||
<action name="mokosuiteclient.conditions.manage" title="COM_MOKOSUITECLIENT_ACL_CONDITIONS" description="COM_MOKOSUITECLIENT_ACL_CONDITIONS_DESC" />
|
||||
|
||||
<!-- Extensions & Catalog -->
|
||||
<action name="mokosuiteclient.extensions" title="COM_MOKOSUITECLIENT_ACL_EXTENSIONS" description="COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC" />
|
||||
</section>
|
||||
</access>
|
||||
|
||||
@@ -1,122 +1,219 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Extension catalog for MokoSuiteClient Extension Manager.
|
||||
Each entry points to the extension's own updates.xml. The installer
|
||||
resolves the latest version and download URL at runtime, respecting
|
||||
the site's configured update channel (dev/stable).
|
||||
|
||||
To add an extension: copy an <extension> block and fill in the fields.
|
||||
MokoSuite Extension Catalog
|
||||
Each entry points to the extension's updates.xml on the main branch.
|
||||
The installer resolves the latest version and download URL at runtime,
|
||||
respecting the site's configured update channel (stable/dev) from
|
||||
Joomla's com_installer params.
|
||||
-->
|
||||
<catalog>
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Platform (Layer 0)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<extension>
|
||||
<name>MokoSuiteClient</name>
|
||||
<element>pkg_mokosuiteclient</element>
|
||||
<type>package</type>
|
||||
<description>Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API.</description>
|
||||
<description>Admin dashboard, security firewall, tenant restrictions, health monitoring, content tools, and REST API.</description>
|
||||
<icon>icon-shield-alt</icon>
|
||||
<category>Platform</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokosuiteclient-platform</article>
|
||||
<protected>true</protected>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/dev/updates.xml</updateserver>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteClientHQ</name>
|
||||
<element>pkg_mokosuiteclienthq</element>
|
||||
<name>MokoSuiteHQ</name>
|
||||
<element>pkg_mokosuitehq</element>
|
||||
<type>package</type>
|
||||
<description>Centralized control panel for managing all MokoSuiteClient client installations.</description>
|
||||
<description>Centralized control panel for managing all MokoSuite client installations.</description>
|
||||
<icon>icon-tachometer-alt</icon>
|
||||
<category>Platform</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokosuiteclient-base</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientHQ/raw/branch/dev/updates.xml</updateserver>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteHQ/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoOnyx</name>
|
||||
<element>mokoonyx</element>
|
||||
<type>template</type>
|
||||
<description>Modern Joomla site template with dark mode, custom layouts, and MokoSuiteClient integration.</description>
|
||||
<icon>icon-paint-brush</icon>
|
||||
<category>Templates</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokoonyx-template</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomOpenGraph</name>
|
||||
<element>pkg_mokoog</element>
|
||||
<type>package</type>
|
||||
<description>Open Graph, Twitter Card, and social sharing meta tags for articles, categories, and pages.</description>
|
||||
<icon>icon-share-alt</icon>
|
||||
<category>SEO</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoomopengraph</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteClientBackup</name>
|
||||
<element>pkg_mokojoombackup</element>
|
||||
<name>MokoSuiteBackup</name>
|
||||
<element>pkg_mokosuitebackup</element>
|
||||
<type>package</type>
|
||||
<description>Full-site backup and restore for Joomla — database, files, and configuration.</description>
|
||||
<icon>icon-archive</icon>
|
||||
<category>Tools</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokosuiteclientbackup</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientBackup/raw/branch/dev/updates.xml</updateserver>
|
||||
<category>Platform</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Business Suite (Layers 1-4)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<extension>
|
||||
<name>MokoSuiteCRM</name>
|
||||
<element>pkg_mokosuitecrm</element>
|
||||
<type>package</type>
|
||||
<description>Layer 1 — Contacts, deals pipeline, activities, e-signature, email integration, helpdesk.</description>
|
||||
<icon>icon-address-book</icon>
|
||||
<category>Business</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteERP</name>
|
||||
<element>pkg_mokosuiteerp</element>
|
||||
<type>package</type>
|
||||
<description>Layer 2 — Products, orders, invoicing, inventory, warehouses, accounting, payments.</description>
|
||||
<icon>icon-briefcase</icon>
|
||||
<category>Business</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteERP/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteShop</name>
|
||||
<element>pkg_mokosuiteshop</element>
|
||||
<type>package</type>
|
||||
<description>Layer 3 — Product catalog, shopping cart, checkout, coupons. Requires MokoSuiteERP.</description>
|
||||
<icon>icon-shopping-cart</icon>
|
||||
<category>Business</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteShop/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuitePOS</name>
|
||||
<element>pkg_mokosuitepos</element>
|
||||
<type>package</type>
|
||||
<description>Layer 3 — Touch-screen POS, multi-terminal, cash register, receipt printing.</description>
|
||||
<icon>icon-calculator</icon>
|
||||
<category>Business</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuitePOS/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteMRP</name>
|
||||
<element>pkg_mokosuitemrp</element>
|
||||
<type>package</type>
|
||||
<description>Layer 3 — BOM, manufacturing orders, workstation management, production scheduling.</description>
|
||||
<icon>icon-cog</icon>
|
||||
<category>Business</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteMRP/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteHRM</name>
|
||||
<element>pkg_mokosuitehrm</element>
|
||||
<type>package</type>
|
||||
<description>Layer 3 — Human Resource Management: employees, leave, expenses, payroll, recruiting.</description>
|
||||
<icon>icon-users</icon>
|
||||
<category>Business</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteHRM/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteRestaurant</name>
|
||||
<element>pkg_mokosuiterestaurant</element>
|
||||
<type>package</type>
|
||||
<description>Layer 4 — Floor plan, table management, kitchen display, split bills, online ordering.</description>
|
||||
<icon>icon-utensils</icon>
|
||||
<category>Industry</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteRestaurant/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteChild</name>
|
||||
<element>pkg_mokosuitechild</element>
|
||||
<type>package</type>
|
||||
<description>Layer 2 — Child Care Management: enrollment, attendance, billing, parent portal.</description>
|
||||
<icon>icon-child</icon>
|
||||
<category>Industry</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteChild/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteNPO</name>
|
||||
<element>pkg_mokosuitenpo</element>
|
||||
<type>package</type>
|
||||
<description>Nonprofit management: donors, donations, campaigns, grants, volunteers, events.</description>
|
||||
<icon>icon-heart</icon>
|
||||
<category>Industry</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteNPO/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteField</name>
|
||||
<element>pkg_mokosuitefield</element>
|
||||
<type>package</type>
|
||||
<description>Field Service — dispatch, work orders, scheduling, mobile tech, plumbing/HVAC.</description>
|
||||
<icon>icon-wrench</icon>
|
||||
<category>Industry</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteCreate</name>
|
||||
<element>pkg_mokosuitecreate</element>
|
||||
<type>package</type>
|
||||
<description>Layer 2 — Creative Agency: projects, tasks, timesheets, client proofing.</description>
|
||||
<icon>icon-paint-brush</icon>
|
||||
<category>Industry</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCreate/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Content & Community
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<extension>
|
||||
<name>MokoSuiteForms</name>
|
||||
<element>pkg_mokosuiteforms</element>
|
||||
<type>package</type>
|
||||
<description>Form builder — custom forms, submissions, notifications, and data exports.</description>
|
||||
<icon>icon-list-alt</icon>
|
||||
<category>Content</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteForms/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteCommunity</name>
|
||||
<element>pkg_mokosuitecommunity</element>
|
||||
<type>package</type>
|
||||
<description>Community profiles, connections, and activity streams for Joomla.</description>
|
||||
<icon>icon-users</icon>
|
||||
<category>Content</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCommunity/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteCross</name>
|
||||
<element>pkg_mokosuitecross</element>
|
||||
<type>package</type>
|
||||
<description>Cross-posting Joomla content to social media, email marketing, and chat platforms.</description>
|
||||
<icon>icon-share-alt</icon>
|
||||
<category>Content</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCross/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteOpenGraph</name>
|
||||
<element>pkg_mokosuiteopengraph</element>
|
||||
<type>package</type>
|
||||
<description>Open Graph, Twitter Card, JSON-LD structured data, and social sharing meta tags.</description>
|
||||
<icon>icon-share-alt</icon>
|
||||
<category>SEO</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoSuiteStoreLocator</name>
|
||||
<element>pkg_mokosuitestorelocator</element>
|
||||
<type>package</type>
|
||||
<description>Interactive map, location search, and admin management for store locations.</description>
|
||||
<icon>icon-map-marker-alt</icon>
|
||||
<category>Content</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Standalone Extensions (MokoJoom)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<extension>
|
||||
<name>MokoJoomHero</name>
|
||||
<element>mod_mokojoomhero</element>
|
||||
<type>module</type>
|
||||
<description>Random hero image module from a configurable folder.</description>
|
||||
<description>Hero module — image slideshow, video backgrounds, solid color/gradient, parallax.</description>
|
||||
<icon>icon-image</icon>
|
||||
<category>Modules</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoomhero</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/dev/updates.xml</updateserver>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Templates
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<extension>
|
||||
<name>MokoJoomCommunity</name>
|
||||
<element>pkg_mokojoomcommunity</element>
|
||||
<type>package</type>
|
||||
<description>Community Builder integration package with custom fields and user management.</description>
|
||||
<icon>icon-users</icon>
|
||||
<category>Community</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoomcommunity</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCommunity/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomCross</name>
|
||||
<element>plg_system_mokojoomcross</element>
|
||||
<type>plugin</type>
|
||||
<description>Cross-extension integration plugin for Joomla component interoperability.</description>
|
||||
<icon>icon-link</icon>
|
||||
<category>Plugins</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoomcross</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomStoreLocator</name>
|
||||
<element>mod_mokojoomstorelocator</element>
|
||||
<type>module</type>
|
||||
<description>Store locator module with Google Maps integration and search.</description>
|
||||
<icon>icon-map-marker-alt</icon>
|
||||
<category>Modules</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoomstorelocator</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>DPCalendar API</name>
|
||||
<element>mokodpcalendarapi</element>
|
||||
<type>plugin</type>
|
||||
<description>Web Services plugin exposing DPCalendar events and calendars via REST API.</description>
|
||||
<icon>icon-calendar</icon>
|
||||
<category>Plugins</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokodpcalendarapi</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoDPCalendarAPI/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>Gallery Calendar</name>
|
||||
<element>mokogallerycalendar</element>
|
||||
<type>plugin</type>
|
||||
<description>JoomGallery and DPCalendar integration — link galleries to events.</description>
|
||||
<icon>icon-images</icon>
|
||||
<category>Plugins</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokogallerycalendar</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoGalleryCalendar/raw/branch/dev/updates.xml</updateserver>
|
||||
<name>MokoOnyx</name>
|
||||
<element>mokoonyx</element>
|
||||
<type>template</type>
|
||||
<description>Modern Joomla site template with dark mode, custom layouts, and MokoSuite integration.</description>
|
||||
<icon>icon-paint-brush</icon>
|
||||
<category>Templates</category>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml</updateserver>
|
||||
</extension>
|
||||
</catalog>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<config>
|
||||
<fieldset name="general" label="General" description="General component settings.">
|
||||
<field name="brand_name" type="text" default="MokoSuiteClient"
|
||||
<field name="brand_name" type="text" default="MokoSuite"
|
||||
label="Brand Name"
|
||||
description="Displayed in the admin sidebar, dashboard, and emails."
|
||||
hint="MokoSuiteClient" />
|
||||
hint="MokoSuite" />
|
||||
<field name="support_email" type="email" default=""
|
||||
label="Support Email"
|
||||
description="Reply-to address for outbound notification emails."
|
||||
hint="support@example.com" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
|
||||
<fieldset name="notifications" label="Notifications" description="Email and push notification settings.">
|
||||
<field name="admin_emails" type="text" default=""
|
||||
label="Admin Email Addresses"
|
||||
description="Comma-separated email addresses to receive all notifications."
|
||||
@@ -31,7 +31,7 @@
|
||||
<field name="spacer_ntfy" type="spacer" label="Push Notifications (ntfy)" />
|
||||
<field name="ntfy_enabled" type="radio" default="0"
|
||||
label="Enable ntfy Push"
|
||||
description="Send push notifications via ntfy for ticket and security events."
|
||||
description="Send push notifications via ntfy for security and system events."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
@@ -40,13 +40,13 @@
|
||||
label="ntfy Server URL"
|
||||
description="Full URL to your ntfy server."
|
||||
showon="ntfy_enabled:1" />
|
||||
<field name="ntfy_topic" type="text" default="mokosuiteclient-tickets"
|
||||
label="Ticket Topic"
|
||||
description="ntfy topic name for helpdesk ticket notifications."
|
||||
<field name="ntfy_topic" type="text" default="mokosuite-alerts"
|
||||
label="Alert Topic"
|
||||
description="ntfy topic name for general alert notifications."
|
||||
showon="ntfy_enabled:1" />
|
||||
<field name="ntfy_security_topic" type="text" default="mokosuiteclient-security"
|
||||
<field name="ntfy_security_topic" type="text" default="mokosuite-security"
|
||||
label="Security Topic"
|
||||
description="ntfy topic name for security alert notifications. Falls back to ticket topic if empty."
|
||||
description="ntfy topic name for security alerts. Falls back to alert topic if empty."
|
||||
showon="ntfy_enabled:1" />
|
||||
<field name="ntfy_token" type="password" default=""
|
||||
label="ntfy Auth Token"
|
||||
@@ -54,59 +54,42 @@
|
||||
showon="ntfy_enabled:1" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
|
||||
<field name="default_category" type="sql" default=""
|
||||
label="Default Ticket Category"
|
||||
description="Category assigned to tickets without a selection."
|
||||
query="SELECT id AS value, title AS text FROM #__mokosuiteclient_ticket_categories WHERE published = 1 ORDER BY ordering" />
|
||||
<field name="autoclose_days" type="number" default="7"
|
||||
label="Auto-Close After (days)"
|
||||
description="Resolved tickets are auto-closed after this many days. 0 = disabled." />
|
||||
<field name="kb_search_enabled" type="radio" default="1"
|
||||
label="KB Search on Ticket Forms"
|
||||
description="Show knowledge base search before ticket submission."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="satisfaction_enabled" type="radio" default="1"
|
||||
label="Satisfaction Ratings"
|
||||
description="Show rating prompt on resolved tickets."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="max_attachment_size" type="number" default="10"
|
||||
label="Max Attachment Size (MB)"
|
||||
description="Maximum upload size per file in megabytes." />
|
||||
<fieldset name="content_tools" label="Content Tools" description="Settings for content tag engines and replacements.">
|
||||
<field name="spacer_snippets" type="spacer" label="Snippets" />
|
||||
<field name="snippets_default_category" type="text" default=""
|
||||
label="Default Snippet Category"
|
||||
description="Category assigned to new snippets if none selected." />
|
||||
|
||||
<field name="spacer_templates" type="spacer" label="Content Templates" />
|
||||
<field name="templates_default_category" type="text" default=""
|
||||
label="Default Template Category"
|
||||
description="Category assigned to new content templates if none selected." />
|
||||
|
||||
<field name="spacer_replacements" type="spacer" label="Replacements" />
|
||||
<field name="replacements_max_rules" type="number" default="100"
|
||||
label="Max Active Rules"
|
||||
description="Maximum number of replacement rules processed per page load. 0 = unlimited." />
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="email_to_ticket" label="Email-to-Ticket (IMAP)" description="Create tickets from incoming emails via IMAP polling.">
|
||||
<field name="imap_host" type="text" default=""
|
||||
label="IMAP Server"
|
||||
description="IMAP hostname (e.g. imap.gmail.com)"
|
||||
hint="imap.gmail.com" />
|
||||
<field name="imap_port" type="number" default="993"
|
||||
label="Port"
|
||||
description="IMAP port (993 for SSL, 143 for plain)" />
|
||||
<field name="imap_ssl" type="radio" default="1"
|
||||
label="Use SSL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="imap_user" type="text" default=""
|
||||
label="Username"
|
||||
description="IMAP login username or email address." />
|
||||
<field name="imap_password" type="password" default=""
|
||||
label="Password"
|
||||
description="IMAP password or app-specific password." />
|
||||
<field name="imap_folder" type="text" default="INBOX"
|
||||
label="Inbox Folder"
|
||||
description="IMAP folder to poll for new messages." />
|
||||
<field name="imap_processed_folder" type="text" default="INBOX.Processed"
|
||||
label="Processed Folder"
|
||||
description="Move processed emails to this folder. Leave empty to just mark as read." />
|
||||
<fieldset name="impersonation" label="User Impersonation" description="Skeleton Key — log into the frontend as another user for support.">
|
||||
<field name="skeleton_key_control_groups" type="usergrouplist" default="8"
|
||||
label="Groups Allowed to Impersonate"
|
||||
description="User groups that can log in as another user."
|
||||
multiple="true"
|
||||
layout="joomla.form.field.list-fancy-select" />
|
||||
<field name="skeleton_key_target_groups" type="usergrouplist" default="2"
|
||||
label="Groups That Can Be Impersonated"
|
||||
description="User groups whose accounts can be accessed via impersonation."
|
||||
multiple="true"
|
||||
layout="joomla.form.field.list-fancy-select" />
|
||||
<field name="skeleton_key_blocked_groups" type="usergrouplist" default="7,8"
|
||||
label="Groups That Cannot Be Impersonated"
|
||||
description="User groups protected from impersonation (overrides target groups)."
|
||||
multiple="true"
|
||||
layout="joomla.form.field.list-fancy-select" />
|
||||
<field name="skeleton_key_cookie_lifetime" type="number" default="10"
|
||||
label="Cookie Lifetime (seconds)"
|
||||
description="How long the impersonation cookie remains valid. Short values are more secure." />
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="permissions" label="COM_MOKOSUITECLIENT_ACL_TITLE"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
; MokoSuiteClient Admin Dashboard - Language Strings
|
||||
; MokoSuite Admin Dashboard - Language Strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuiteClient Control Panel"
|
||||
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuite Control Panel"
|
||||
|
||||
; Joomla core fallback keys (in case language files are corrupt/missing)
|
||||
; Joomla core fallback keys
|
||||
COM_ACTIONLOGS_DISABLED="User Action Logging is disabled. Please enable the "Action Log - Joomla" plugin."
|
||||
COM_MOKOSUITECLIENT_SITE="Site"
|
||||
COM_MOKOSUITECLIENT_DATABASE="Database"
|
||||
@@ -23,22 +23,29 @@ COM_MOKOSUITECLIENT_EXTENSIONS_TITLE="Moko Extensions"
|
||||
COM_MOKOSUITECLIENT_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism — each package registers its own update server."
|
||||
COM_MOKOSUITECLIENT_EXTENSIONS_LINK="Moko Extensions"
|
||||
COM_MOKOSUITECLIENT_HTACCESS_TITLE=".htaccess Maker"
|
||||
COM_MOKOSUITECLIENT_TICKETS_TITLE="Helpdesk"
|
||||
|
||||
; ACL
|
||||
COM_MOKOSUITECLIENT_ACL_TITLE="Permissions"
|
||||
COM_MOKOSUITECLIENT_ACL_DESC="Manage access permissions for MokoSuite component features."
|
||||
COM_MOKOSUITECLIENT_ACL_DASHBOARD="View Dashboard"
|
||||
COM_MOKOSUITECLIENT_ACL_DASHBOARD_DESC="Allow viewing the MokoSuiteClient control panel dashboard."
|
||||
COM_MOKOSUITECLIENT_ACL_DASHBOARD_DESC="Allow viewing the MokoSuite control panel dashboard."
|
||||
COM_MOKOSUITECLIENT_ACL_EXTENSIONS="Manage Extensions"
|
||||
COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions."
|
||||
COM_MOKOSUITECLIENT_ACL_HTACCESS="Manage .htaccess"
|
||||
COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration."
|
||||
COM_MOKOSUITECLIENT_ACL_TICKETS="View Tickets"
|
||||
COM_MOKOSUITECLIENT_ACL_TICKETS_DESC="Allow viewing helpdesk tickets."
|
||||
COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE="Create Tickets"
|
||||
COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets."
|
||||
COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN="Assign Tickets"
|
||||
COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users."
|
||||
COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE="Toggle Plugins"
|
||||
COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoSuiteClient feature plugins."
|
||||
COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoSuite feature plugins."
|
||||
COM_MOKOSUITECLIENT_ACL_CACHE="Clear Cache"
|
||||
COM_MOKOSUITECLIENT_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
|
||||
COM_MOKOSUITECLIENT_ACL_HTACCESS="Manage .htaccess"
|
||||
COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess and nginx configuration."
|
||||
COM_MOKOSUITECLIENT_ACL_WAFLOG="View WAF Log"
|
||||
COM_MOKOSUITECLIENT_ACL_WAFLOG_DESC="Allow viewing the Web Application Firewall activity log."
|
||||
COM_MOKOSUITECLIENT_ACL_IMPERSONATE="Impersonate Users"
|
||||
COM_MOKOSUITECLIENT_ACL_IMPERSONATE_DESC="Allow logging into the frontend as another user for support purposes."
|
||||
COM_MOKOSUITECLIENT_ACL_SNIPPETS="Manage Snippets"
|
||||
COM_MOKOSUITECLIENT_ACL_SNIPPETS_DESC="Allow creating, editing, and deleting reusable content snippets."
|
||||
COM_MOKOSUITECLIENT_ACL_TEMPLATES="Manage Content Templates"
|
||||
COM_MOKOSUITECLIENT_ACL_TEMPLATES_DESC="Allow creating, editing, and deleting article content templates."
|
||||
COM_MOKOSUITECLIENT_ACL_REPLACEMENTS="Manage Replacements"
|
||||
COM_MOKOSUITECLIENT_ACL_REPLACEMENTS_DESC="Allow creating, editing, and deleting text replacement rules."
|
||||
COM_MOKOSUITECLIENT_ACL_CONDITIONS="Manage Conditions"
|
||||
COM_MOKOSUITECLIENT_ACL_CONDITIONS_DESC="Allow creating, editing, and deleting display condition sets for modules and content."
|
||||
|
||||
@@ -227,3 +227,126 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_license_cache` (
|
||||
PRIMARY KEY (`dlid_hash`),
|
||||
KEY `idx_checked` (`checked_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Conditions Engine — rule-based display conditions
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`alias` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`description` TEXT NOT NULL,
|
||||
`category` VARCHAR(50) NOT NULL DEFAULT '',
|
||||
`color` VARCHAR(8) DEFAULT NULL,
|
||||
`match_all` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`published` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`hash` VARCHAR(32) NOT NULL DEFAULT '',
|
||||
`checked_out` INT UNSIGNED DEFAULT NULL,
|
||||
`checked_out_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_published` (`published`),
|
||||
KEY `idx_alias` (`alias`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions_groups` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`condition_id` INT UNSIGNED NOT NULL,
|
||||
`match_all` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`ordering` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_condition` (`condition_id`),
|
||||
KEY `idx_ordering` (`condition_id`, `ordering`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions_rules` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`group_id` INT UNSIGNED NOT NULL,
|
||||
`type` VARCHAR(50) NOT NULL DEFAULT '',
|
||||
`exclude` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`params` TEXT NOT NULL,
|
||||
`ordering` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_group` (`group_id`),
|
||||
KEY `idx_type` (`type`),
|
||||
KEY `idx_ordering` (`group_id`, `ordering`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions_map` (
|
||||
`condition_id` INT UNSIGNED NOT NULL,
|
||||
`extension` VARCHAR(50) NOT NULL DEFAULT '',
|
||||
`item_id` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY `idx_unique` (`condition_id`, `item_id`, `extension`),
|
||||
KEY `idx_ext_item` (`extension`, `item_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- Snippets — reusable text/HTML blocks insertable via {snippet}
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_snippets` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`alias` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`description` TEXT NOT NULL,
|
||||
`category` VARCHAR(50) NOT NULL DEFAULT '',
|
||||
`color` VARCHAR(8) DEFAULT NULL,
|
||||
`content` MEDIUMTEXT NOT NULL,
|
||||
`params` TEXT NOT NULL,
|
||||
`published` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
`checked_out` INT UNSIGNED DEFAULT NULL,
|
||||
`checked_out_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_alias` (`alias`),
|
||||
KEY `idx_published` (`published`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- ReReplacer — backend-managed string/regex replacement rules
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_replacements` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`search` TEXT NOT NULL,
|
||||
`replace_value` TEXT NOT NULL,
|
||||
`area` VARCHAR(20) NOT NULL DEFAULT 'both',
|
||||
`regex` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`casesensitive` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`category` VARCHAR(50) NOT NULL DEFAULT '',
|
||||
`published` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`description` TEXT NOT NULL,
|
||||
`enable_in_admin` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`color` VARCHAR(8) DEFAULT NULL,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
`checked_out` INT UNSIGNED DEFAULT NULL,
|
||||
`checked_out_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_published` (`published`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
--
|
||||
-- Content Templates
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_content_templates` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`alias` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`name` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`description` TEXT NOT NULL,
|
||||
`category` VARCHAR(50) NOT NULL DEFAULT '',
|
||||
`color` VARCHAR(8) DEFAULT NULL,
|
||||
`template_data` MEDIUMTEXT NOT NULL,
|
||||
`joomla_category_id` INT NOT NULL DEFAULT 0,
|
||||
`access` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`published` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
`checked_out` INT UNSIGNED DEFAULT NULL,
|
||||
`checked_out_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_published` (`published`),
|
||||
KEY `idx_alias` (`alias`),
|
||||
KEY `idx_category` (`joomla_category_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -24,19 +24,18 @@ class DisplayController extends BaseController
|
||||
* ACL map: view name => required permission.
|
||||
*/
|
||||
private const VIEW_ACL = [
|
||||
'dashboard' => 'mokosuiteclient.dashboard',
|
||||
'extensions' => 'mokosuiteclient.extensions',
|
||||
'htaccess' => 'mokosuiteclient.htaccess',
|
||||
'tickets' => 'mokosuiteclient.tickets',
|
||||
'ticket' => 'mokosuiteclient.tickets',
|
||||
'privacy' => 'core.admin',
|
||||
'waflog' => 'core.admin',
|
||||
'categories' => 'mokosuiteclient.tickets',
|
||||
'canned' => 'mokosuiteclient.tickets',
|
||||
'automation' => 'core.admin',
|
||||
'database' => 'core.admin',
|
||||
'cleanup' => 'mokosuiteclient.cache',
|
||||
'ticketsettings' => 'core.admin',
|
||||
'dashboard' => 'mokosuiteclient.dashboard',
|
||||
'extensions' => 'mokosuiteclient.extensions',
|
||||
'htaccess' => 'mokosuiteclient.htaccess',
|
||||
'privacy' => 'core.admin',
|
||||
'waflog' => 'mokosuiteclient.security.waflog',
|
||||
'automation' => 'core.admin',
|
||||
'database' => 'core.admin',
|
||||
'cleanup' => 'mokosuiteclient.cache',
|
||||
'snippets' => 'mokosuiteclient.snippets.manage',
|
||||
'templates' => 'mokosuiteclient.templates.manage',
|
||||
'replacements' => 'mokosuiteclient.replacements.manage',
|
||||
'conditions' => 'mokosuiteclient.conditions.manage',
|
||||
];
|
||||
|
||||
public function display($cachable = false, $urlparams = [])
|
||||
@@ -142,6 +141,22 @@ class DisplayController extends BaseController
|
||||
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
|
||||
$timestamp = time();
|
||||
|
||||
// Discover all MokoSuite ecosystem packages for HQ
|
||||
$mokoPackages = [];
|
||||
try {
|
||||
$pkgDb = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||
$pkgQuery = $pkgDb->getQuery(true)
|
||||
->select([$pkgDb->quoteName('element'), $pkgDb->quoteName('manifest_cache')])
|
||||
->from($pkgDb->quoteName('#__extensions'))
|
||||
->where('(' . $pkgDb->quoteName('element') . ' LIKE ' . $pkgDb->quote('pkg_mokosuite%')
|
||||
. ' OR ' . $pkgDb->quoteName('element') . ' LIKE ' . $pkgDb->quote('pkg_mokojoom%') . ')');
|
||||
$pkgDb->setQuery($pkgQuery);
|
||||
foreach ($pkgDb->loadObjectList() ?: [] as $pkg) {
|
||||
$m = json_decode($pkg->manifest_cache ?? '{}');
|
||||
$mokoPackages[$pkg->element] = $m->version ?? '';
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
$payload = json_encode([
|
||||
'token' => $healthToken,
|
||||
'domain' => $domain,
|
||||
@@ -150,6 +165,7 @@ class DisplayController extends BaseController
|
||||
'joomla_version' => (new \Joomla\CMS\Version())->getShortVersion(),
|
||||
'php_version' => PHP_VERSION,
|
||||
'timestamp' => $timestamp,
|
||||
'moko_packages' => $mokoPackages,
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// RSA sign the request
|
||||
@@ -348,186 +364,67 @@ class DisplayController extends BaseController
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Tickets
|
||||
// Support PIN
|
||||
// ==================================================================
|
||||
|
||||
public function createTicket()
|
||||
public function requestPin()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets.create'))
|
||||
if (!$this->checkAcl('mokosuiteclient.dashboard'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->createTicket([
|
||||
'subject' => $input->getString('subject', ''),
|
||||
'body' => $input->getRaw('body', ''),
|
||||
'priority' => $input->getString('priority', 'normal'),
|
||||
'category_id' => $input->getInt('category_id', 0),
|
||||
'contact_id' => $input->getInt('contact_id', 0),
|
||||
'assign_users' => $input->get('assign_users', [], 'ARRAY'),
|
||||
'assign_groups' => $input->get('assign_groups', [], 'ARRAY'),
|
||||
'custom_fields' => $input->get('custom_fields', [], 'ARRAY'),
|
||||
]));
|
||||
}
|
||||
|
||||
public function addTicketReply()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->addReply(
|
||||
$input->getInt('ticket_id', 0),
|
||||
$input->getRaw('body', ''),
|
||||
(bool) $input->getInt('is_internal', 0)
|
||||
));
|
||||
}
|
||||
|
||||
public function updateTicketStatus()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->updateStatus(
|
||||
$input->getInt('ticket_id', 0),
|
||||
$input->getInt('status', 0)
|
||||
));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Ticket Settings — Status/Priority CRUD
|
||||
// ==================================================================
|
||||
|
||||
public function saveStatus()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$this->jsonResponse($this->getModel('Tickets')->saveStatus([
|
||||
'id' => $input->getInt('id', 0),
|
||||
'title' => $input->getString('title', ''),
|
||||
'alias' => $input->getString('alias', ''),
|
||||
'color' => $input->getString('color', 'bg-secondary'),
|
||||
'is_default' => $input->getInt('is_default', 0),
|
||||
'is_closed' => $input->getInt('is_closed', 0),
|
||||
'ordering' => $input->getInt('ordering', 0),
|
||||
]));
|
||||
}
|
||||
|
||||
public function deleteStatus()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$this->jsonResponse($this->getModel('Tickets')->deleteStatus($id));
|
||||
}
|
||||
|
||||
public function savePriority()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$this->jsonResponse($this->getModel('Tickets')->savePriority([
|
||||
'id' => $input->getInt('id', 0),
|
||||
'title' => $input->getString('title', ''),
|
||||
'alias' => $input->getString('alias', ''),
|
||||
'color' => $input->getString('color', 'bg-secondary'),
|
||||
'is_default' => $input->getInt('is_default', 0),
|
||||
'ordering' => $input->getInt('ordering', 0),
|
||||
]));
|
||||
}
|
||||
|
||||
public function deletePriority()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$this->jsonResponse($this->getModel('Tickets')->deletePriority($id));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// KB Search
|
||||
// ==================================================================
|
||||
|
||||
public function searchKb()
|
||||
{
|
||||
$query = Factory::getApplication()->getInput()->getString('q', '');
|
||||
|
||||
if (strlen($query) < 3)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
|
||||
|
||||
$results = $db->setQuery(
|
||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')])
|
||||
->from($db->quoteName('#__finder_links', 'l'))
|
||||
->where($db->quoteName('l.published') . ' = 1')
|
||||
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
|
||||
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
|
||||
->order($db->quoteName('l.title') . ' ASC')
|
||||
->setLimit(8)
|
||||
)->loadObjectList() ?: [];
|
||||
->select([$db->quoteName('extension_id'), $db->quoteName('params')])
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
);
|
||||
$ext = $db->loadObject();
|
||||
|
||||
foreach ($results as $r)
|
||||
if (!$ext)
|
||||
{
|
||||
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Core plugin not found.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse(['results' => $results]);
|
||||
$params = json_decode($ext->params, true) ?: [];
|
||||
$token = $params['health_api_token'] ?? '';
|
||||
|
||||
if (empty($token))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Health token not configured.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$params['support_pin_requested_at'] = $now;
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
|
||||
->where($db->quoteName('extension_id') . ' = ' . (int) $ext->extension_id)
|
||||
)->execute();
|
||||
|
||||
$pinTtl = 72 * 3600;
|
||||
$window = floor($now / $pinTtl);
|
||||
$hash = hash_hmac('sha256', (string) $window, $token);
|
||||
$pin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
|
||||
|
||||
$this->jsonResponse(['success' => true, 'pin' => $pin, 'message' => 'PIN generated — valid for 72 hours.']);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('KB search failed: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||
$this->jsonResponse(['results' => [], 'error' => 'Search unavailable']);
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Error: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,218 +465,6 @@ class DisplayController extends BaseController
|
||||
$this->jsonResponse($model->cleanDirectory($dirKey));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Helpdesk CRUD (#137, #138, #139)
|
||||
// ==================================================================
|
||||
|
||||
public function saveCategory()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$id = $input->getInt('id', 0);
|
||||
$data = (object) [
|
||||
'title' => $input->getString('title', ''),
|
||||
'alias' => \Joomla\CMS\Filter\OutputFilter::stringURLSafe($input->getString('title', '')),
|
||||
'sla_response_minutes' => $input->getInt('sla_response_minutes', 480),
|
||||
'sla_resolution_minutes' => $input->getInt('sla_resolution_minutes', 2880),
|
||||
'auto_assign_user' => $input->getInt('auto_assign_user', 0) ?: null,
|
||||
'published' => $input->getInt('published', 1),
|
||||
];
|
||||
if ($id) {
|
||||
$data->id = $id;
|
||||
$db->updateObject('#__mokosuiteclient_ticket_categories', $data, 'id');
|
||||
} else {
|
||||
$data->ordering = 0;
|
||||
$db->insertObject('#__mokosuiteclient_ticket_categories', $data, 'id');
|
||||
}
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Category saved.', 'id' => (int) $data->id]);
|
||||
}
|
||||
|
||||
public function deleteCategory()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_categories')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
|
||||
}
|
||||
|
||||
public function reorderCategory()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||
$db = Factory::getDbo();
|
||||
foreach ($order as $i => $id) {
|
||||
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_categories') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||
}
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||
}
|
||||
|
||||
public function saveCanned()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$data = (object) [
|
||||
'title' => $input->getString('title', ''),
|
||||
'body' => $input->getRaw('body', ''),
|
||||
'category_id' => $input->getInt('category_id', 0) ?: null,
|
||||
'ordering' => 0,
|
||||
];
|
||||
$id = $input->getInt('id', 0);
|
||||
if ($id) { $data->id = $id; $db->updateObject('#__mokosuiteclient_ticket_canned', $data, 'id'); }
|
||||
else { $db->insertObject('#__mokosuiteclient_ticket_canned', $data, 'id'); }
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Canned response saved.', 'id' => (int) $data->id]);
|
||||
}
|
||||
|
||||
public function deleteCanned()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_canned')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
|
||||
}
|
||||
|
||||
public function reorderCanned()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||
$db = Factory::getDbo();
|
||||
foreach ($order as $i => $id) {
|
||||
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_canned') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||
}
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||
}
|
||||
|
||||
public function uploadAttachment()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$ticketId = $input->getInt('ticket_id', 0);
|
||||
$replyId = $input->getInt('reply_id', 0) ?: null;
|
||||
if (!$ticketId) { $this->jsonResponse(['success' => false, 'message' => 'Missing ticket_id']); return; }
|
||||
$files = $input->files->get('attachments', [], 'raw');
|
||||
if (empty($files) || empty($files['name'])) { $this->jsonResponse(['success' => false, 'message' => 'No files uploaded']); return; }
|
||||
$saved = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::upload($ticketId, $replyId, $files);
|
||||
$this->jsonResponse(['success' => true, 'message' => count($saved) . ' file(s) uploaded', 'count' => count($saved)]);
|
||||
}
|
||||
|
||||
public function downloadAttachment()
|
||||
{
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_ticket_attachments')->where('id = ' . $id));
|
||||
$att = $db->loadObject();
|
||||
if (!$att) { throw new \RuntimeException('Attachment not found', 404); }
|
||||
$path = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getAbsolutePath($att);
|
||||
if (!file_exists($path)) { throw new \RuntimeException('File not found', 404); }
|
||||
$app = Factory::getApplication();
|
||||
$app->setHeader('Content-Type', $att->mimetype ?: 'application/octet-stream');
|
||||
$safeName = str_replace(['"', "\r", "\n"], '', $att->filename);
|
||||
$app->setHeader('Content-Disposition', 'attachment; filename="' . $safeName . '"');
|
||||
$app->setHeader('Content-Length', (string) filesize($path));
|
||||
$app->sendHeaders();
|
||||
readfile($path);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
public function deleteAttachment()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$ok = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::delete($id);
|
||||
$this->jsonResponse(['success' => $ok, 'message' => $ok ? 'Attachment deleted' : 'Not found']);
|
||||
}
|
||||
|
||||
public function rateTicket()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$ticketId = $input->getInt('ticket_id', 0);
|
||||
$rating = $input->getInt('rating', 0);
|
||||
$feedback = $input->getString('feedback', '');
|
||||
if (!$ticketId || $rating < 1 || $rating > 5) {
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Invalid rating (1-5)']);
|
||||
return;
|
||||
}
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
'UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets')
|
||||
. ' SET satisfaction_rating = ' . $rating
|
||||
. ', satisfaction_feedback = ' . $db->quote($feedback)
|
||||
. ', satisfaction_rated_at = ' . $db->quote(Factory::getDate()->toSql())
|
||||
. ' WHERE id = ' . $ticketId
|
||||
)->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Thank you for your feedback!']);
|
||||
}
|
||||
|
||||
public function saveAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$data = (object) [
|
||||
'title' => $input->getString('title', ''),
|
||||
'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
|
||||
'conditions' => $input->getRaw('conditions', '[]'),
|
||||
'actions' => $input->getRaw('actions', '[]'),
|
||||
'behavior' => $input->getString('behavior', 'append'),
|
||||
'enabled' => 1,
|
||||
'ordering' => 0,
|
||||
];
|
||||
$id = $input->getInt('id', 0);
|
||||
if ($id) { $data->id = $id; $db->updateObject('#__mokosuiteclient_ticket_automation', $data, 'id'); }
|
||||
else { $db->insertObject('#__mokosuiteclient_ticket_automation', $data, 'id'); }
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Rule saved.', 'id' => (int) $data->id]);
|
||||
}
|
||||
|
||||
public function deleteAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_automation')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Rule deleted.']);
|
||||
}
|
||||
|
||||
public function toggleAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->update('#__mokosuiteclient_ticket_automation')
|
||||
->set('enabled = ' . $input->getInt('enabled', 0))
|
||||
->where('id = ' . $input->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
|
||||
}
|
||||
|
||||
public function reorderAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||
$db = Factory::getDbo();
|
||||
foreach ($order as $i => $id) {
|
||||
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_automation') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||
}
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Settings Import/Export (#132)
|
||||
// ==================================================================
|
||||
@@ -891,7 +576,7 @@ class DisplayController extends BaseController
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
if (!$this->checkAcl('mokosuiteclient.security.waflog'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
@@ -907,7 +592,7 @@ class DisplayController extends BaseController
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
if (!$this->checkAcl('mokosuiteclient.security.waflog'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
@@ -991,19 +676,6 @@ class DisplayController extends BaseController
|
||||
// Importers
|
||||
// ==================================================================
|
||||
|
||||
public function importAts()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Import')->importAts());
|
||||
}
|
||||
|
||||
public function importAdminTools()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
/**
|
||||
* Conditions Engine — evaluates rule-based display conditions.
|
||||
*
|
||||
* Supports nested groups of rules with AND/OR logic and per-rule exclusion.
|
||||
*
|
||||
* @since 02.48.00
|
||||
*/
|
||||
class ConditionsHelper
|
||||
{
|
||||
/**
|
||||
* Runtime evaluation cache keyed by condition ID.
|
||||
*
|
||||
* @var array<int, bool>
|
||||
*/
|
||||
private static array $cache = [];
|
||||
|
||||
/**
|
||||
* Check whether a condition set passes.
|
||||
*
|
||||
* @param int $conditionId The condition record ID.
|
||||
*
|
||||
* @return bool True when the condition passes (content should display).
|
||||
*/
|
||||
public static function pass(int $conditionId): bool
|
||||
{
|
||||
if (isset(self::$cache[$conditionId])) {
|
||||
return self::$cache[$conditionId];
|
||||
}
|
||||
|
||||
$condition = self::load($conditionId);
|
||||
|
||||
if ($condition === null || !(int) $condition->published) {
|
||||
self::$cache[$conditionId] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
$groups = $condition->groups ?? [];
|
||||
|
||||
if (empty($groups)) {
|
||||
// No groups means no restrictions — pass.
|
||||
self::$cache[$conditionId] = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
$matchAll = (bool) $condition->match_all;
|
||||
|
||||
foreach ($groups as $group) {
|
||||
$groupResult = self::passGroup($group);
|
||||
|
||||
if ($matchAll && !$groupResult) {
|
||||
self::$cache[$conditionId] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$matchAll && $groupResult) {
|
||||
self::$cache[$conditionId] = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// match_all: all passed; match_any: none passed.
|
||||
$result = $matchAll;
|
||||
self::$cache[$conditionId] = $result;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a condition with its groups and rules from the database.
|
||||
*
|
||||
* @param int $conditionId The condition record ID.
|
||||
*
|
||||
* @return object|null The condition object with nested groups/rules, or null.
|
||||
*/
|
||||
public static function load(int $conditionId): ?object
|
||||
{
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
|
||||
// Load the condition record.
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuiteclient_conditions'))
|
||||
->where($db->quoteName('id') . ' = :id')
|
||||
->bind(':id', $conditionId, \Joomla\Database\ParameterType::INTEGER);
|
||||
|
||||
$condition = $db->setQuery($query)->loadObject();
|
||||
|
||||
if ($condition === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load groups.
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuiteclient_conditions_groups'))
|
||||
->where($db->quoteName('condition_id') . ' = :cid')
|
||||
->bind(':cid', $conditionId, \Joomla\Database\ParameterType::INTEGER)
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
|
||||
$groups = $db->setQuery($query)->loadObjectList();
|
||||
|
||||
// Load rules for each group.
|
||||
foreach ($groups as $group) {
|
||||
$groupId = (int) $group->id;
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuiteclient_conditions_rules'))
|
||||
->where($db->quoteName('group_id') . ' = :gid')
|
||||
->bind(':gid', $groupId, \Joomla\Database\ParameterType::INTEGER)
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
|
||||
$group->rules = $db->setQuery($query)->loadObjectList();
|
||||
|
||||
// Decode params JSON on each rule.
|
||||
foreach ($group->rules as $rule) {
|
||||
$rule->params = json_decode($rule->params ?: '{}');
|
||||
}
|
||||
}
|
||||
|
||||
$condition->groups = $groups;
|
||||
|
||||
return $condition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single group (AND/OR its rules).
|
||||
*
|
||||
* @param object $group The group object with a rules array.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function passGroup(object $group): bool
|
||||
{
|
||||
$rules = $group->rules ?? [];
|
||||
|
||||
if (empty($rules)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$matchAll = (bool) $group->match_all;
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$ruleResult = self::passRule($rule);
|
||||
|
||||
// If the rule is an exclusion, invert the result.
|
||||
if ((int) $rule->exclude) {
|
||||
$ruleResult = !$ruleResult;
|
||||
}
|
||||
|
||||
if ($matchAll && !$ruleResult) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$matchAll && $ruleResult) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return $matchAll;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single rule by dispatching to the right type handler.
|
||||
*
|
||||
* @param object $rule The rule object (type, params decoded).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function passRule(object $rule): bool
|
||||
{
|
||||
$params = $rule->params ?? new \stdClass();
|
||||
|
||||
return match ($rule->type) {
|
||||
'menu__menu_item' => self::evalMenuMenuItem($params),
|
||||
'menu__home_page' => self::evalMenuHomePage($params),
|
||||
'visitor__user_group' => self::evalVisitorUserGroup($params),
|
||||
'visitor__access_level' => self::evalVisitorAccessLevel($params),
|
||||
'date__date' => self::evalDateDate($params),
|
||||
'date__day' => self::evalDateDay($params),
|
||||
'other__url' => self::evalOtherUrl($params),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Rule type evaluators
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* menu__menu_item — check if current menu item ID is in selection.
|
||||
*/
|
||||
private static function evalMenuMenuItem(object $params): bool
|
||||
{
|
||||
$selection = self::toIntArray($params->selection ?? []);
|
||||
|
||||
if (empty($selection)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$itemId = (int) $app->getInput()->getInt('Itemid', 0);
|
||||
|
||||
return \in_array($itemId, $selection, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* menu__home_page — check if current page is the site home page.
|
||||
*/
|
||||
private static function evalMenuHomePage(object $params): bool
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$menu = $app->getMenu();
|
||||
|
||||
if ($menu === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$active = $menu->getActive();
|
||||
$default = $menu->getDefault($app->getLanguage()->getTag());
|
||||
|
||||
$isHome = ($active !== null && $default !== null && $active->id === $default->id);
|
||||
|
||||
// params->selection can be [1] for "is home" or [0] for "is not home".
|
||||
$want = (bool) ($params->selection[0] ?? true);
|
||||
|
||||
return $isHome === $want;
|
||||
}
|
||||
|
||||
/**
|
||||
* visitor__user_group — check if current user belongs to specified groups.
|
||||
*/
|
||||
private static function evalVisitorUserGroup(object $params): bool
|
||||
{
|
||||
$selection = self::toIntArray($params->selection ?? []);
|
||||
|
||||
if (empty($selection)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$userGroups = $user ? $user->getAuthorisedGroups() : [];
|
||||
|
||||
$comparison = $params->comparison ?? 'any';
|
||||
|
||||
if ($comparison === 'all') {
|
||||
return empty(array_diff($selection, $userGroups));
|
||||
}
|
||||
|
||||
// Default: any
|
||||
return !empty(array_intersect($selection, $userGroups));
|
||||
}
|
||||
|
||||
/**
|
||||
* visitor__access_level — check if current user has specified access levels.
|
||||
*/
|
||||
private static function evalVisitorAccessLevel(object $params): bool
|
||||
{
|
||||
$selection = self::toIntArray($params->selection ?? []);
|
||||
|
||||
if (empty($selection)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$accessLevels = $user ? $user->getAuthorisedViewLevels() : [];
|
||||
|
||||
$comparison = $params->comparison ?? 'any';
|
||||
|
||||
if ($comparison === 'all') {
|
||||
return empty(array_diff($selection, $accessLevels));
|
||||
}
|
||||
|
||||
return !empty(array_intersect($selection, $accessLevels));
|
||||
}
|
||||
|
||||
/**
|
||||
* date__date — check if current date is before/after/between specified dates.
|
||||
*
|
||||
* params->comparison: 'before', 'after', 'between'
|
||||
* params->selection: [start_date] or [start_date, end_date]
|
||||
*/
|
||||
private static function evalDateDate(object $params): bool
|
||||
{
|
||||
$comparison = $params->comparison ?? 'after';
|
||||
$selection = (array) ($params->selection ?? []);
|
||||
|
||||
if (empty($selection)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$now = Factory::getDate()->toUnix();
|
||||
|
||||
return match ($comparison) {
|
||||
'before' => $now < strtotime($selection[0]),
|
||||
'after' => $now > strtotime($selection[0]),
|
||||
'between' => isset($selection[1])
|
||||
&& $now >= strtotime($selection[0])
|
||||
&& $now <= strtotime($selection[1]),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* date__day — check if current day of week matches selection.
|
||||
*
|
||||
* params->selection: array of day numbers (1=Monday .. 7=Sunday, ISO-8601).
|
||||
*/
|
||||
private static function evalDateDay(object $params): bool
|
||||
{
|
||||
$selection = self::toIntArray($params->selection ?? []);
|
||||
|
||||
if (empty($selection)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$today = (int) Factory::getDate()->format('N'); // 1=Mon, 7=Sun
|
||||
|
||||
return \in_array($today, $selection, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* other__url — check if current URL matches a regex pattern.
|
||||
*
|
||||
* params->selection: array of regex patterns (without delimiters).
|
||||
*/
|
||||
private static function evalOtherUrl(object $params): bool
|
||||
{
|
||||
$patterns = (array) ($params->selection ?? []);
|
||||
|
||||
if (empty($patterns)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$url = Uri::getInstance()->toString();
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
$pattern = trim($pattern);
|
||||
|
||||
if ($pattern === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wrap in delimiters, escape internal delimiter.
|
||||
$safePattern = str_replace('#', '\\#', $pattern);
|
||||
if (@preg_match('#' . $safePattern . '#i', $url)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Mapping helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get all condition IDs mapped to a specific extension/item pair.
|
||||
*
|
||||
* @param string $extension The extension identifier (e.g. 'mod_custom').
|
||||
* @param int $itemId The item ID within that extension.
|
||||
*
|
||||
* @return int[] Array of condition IDs.
|
||||
*/
|
||||
public static function getConditionsForItem(string $extension, int $itemId): array
|
||||
{
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('condition_id'))
|
||||
->from($db->quoteName('#__mokosuiteclient_conditions_map'))
|
||||
->where($db->quoteName('extension') . ' = :ext')
|
||||
->where($db->quoteName('item_id') . ' = :iid')
|
||||
->bind(':ext', $extension)
|
||||
->bind(':iid', $itemId, \Joomla\Database\ParameterType::INTEGER);
|
||||
|
||||
return $db->setQuery($query)->loadColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item should display based on its mapped conditions.
|
||||
*
|
||||
* If no conditions are mapped, the item displays (returns true).
|
||||
* If conditions are mapped, ALL must pass for the item to display.
|
||||
*
|
||||
* @param string $extension The extension identifier.
|
||||
* @param int $itemId The item ID.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function shouldDisplay(string $extension, int $itemId): bool
|
||||
{
|
||||
$conditionIds = self::getConditionsForItem($extension, $itemId);
|
||||
|
||||
if (empty($conditionIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($conditionIds as $conditionId) {
|
||||
if (!self::pass((int) $conditionId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a condition by its alias string.
|
||||
*
|
||||
* @param string $alias The condition alias.
|
||||
*
|
||||
* @return bool True when the condition passes.
|
||||
*
|
||||
* @since 02.48.00
|
||||
*/
|
||||
public static function passByAlias(string $alias): bool
|
||||
{
|
||||
$id = self::resolveAlias($alias);
|
||||
|
||||
if ($id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::pass($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a condition reference that may be an integer ID or an alias string.
|
||||
*
|
||||
* @param string $ref The reference (numeric ID or alias).
|
||||
*
|
||||
* @return int|null The condition ID, or null if not found.
|
||||
*
|
||||
* @since 02.48.00
|
||||
*/
|
||||
public static function resolveAlias(string $ref): ?int
|
||||
{
|
||||
if (is_numeric($ref)) {
|
||||
return (int) $ref;
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__mokosuiteclient_conditions'))
|
||||
->where($db->quoteName('alias') . ' = :alias')
|
||||
->bind(':alias', $ref);
|
||||
|
||||
$id = $db->setQuery($query)->loadResult();
|
||||
|
||||
return $id !== null ? (int) $id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single inline rule (public wrapper around passRule).
|
||||
*
|
||||
* @param string $type The rule type (e.g. 'visitor__access_level').
|
||||
* @param object $params The rule params object.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 02.48.00
|
||||
*/
|
||||
public static function evaluateInlineRule(string $type, object $params): bool
|
||||
{
|
||||
$rule = (object) [
|
||||
'type' => $type,
|
||||
'params' => $params,
|
||||
];
|
||||
|
||||
return self::passRule($rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the evaluation cache (useful between requests in testing).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
self::$cache = [];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Internal utilities
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Normalize a mixed selection value into an array of integers.
|
||||
*
|
||||
* @param mixed $value Scalar, array, or object.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private static function toIntArray(mixed $value): array
|
||||
{
|
||||
if (\is_object($value)) {
|
||||
$value = (array) $value;
|
||||
}
|
||||
|
||||
if (!\is_array($value)) {
|
||||
$value = [$value];
|
||||
}
|
||||
|
||||
return array_map('intval', array_values($value));
|
||||
}
|
||||
}
|
||||
@@ -213,30 +213,46 @@ class DashboardModel extends BaseDatabaseModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installed MokoSuiteClient component and modules with versions.
|
||||
* Discover all installed MokoSuite ecosystem extensions.
|
||||
*
|
||||
* @return array Array of extension objects with name, element, type, version.
|
||||
* Fuzzy-matches packages, components, modules, plugins, and libraries
|
||||
* by element name containing "mokosuite", "mokosuiteclient", "mokojoom",
|
||||
* or "moko" prefix patterns.
|
||||
*
|
||||
* @return array Extension objects with name, element, type, version, enabled, family.
|
||||
*/
|
||||
public function getMokoExtensions(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$el = $db->quoteName('element');
|
||||
|
||||
// Fuzzy match: any extension whose element contains moko patterns
|
||||
$patterns = [
|
||||
$el . ' LIKE ' . $db->quote('pkg_mokosuite%'),
|
||||
$el . ' LIKE ' . $db->quote('com_mokosuite%'),
|
||||
$el . ' LIKE ' . $db->quote('mod_mokosuite%'),
|
||||
$el . ' LIKE ' . $db->quote('mokosuite%'),
|
||||
$el . ' LIKE ' . $db->quote('mokosuiteclient%'),
|
||||
$el . ' LIKE ' . $db->quote('pkg_mokojoom%'),
|
||||
$el . ' LIKE ' . $db->quote('com_mokojoom%'),
|
||||
$el . ' LIKE ' . $db->quote('mod_mokojoom%'),
|
||||
$el . ' LIKE ' . $db->quote('mokojoom%'),
|
||||
$el . ' LIKE ' . $db->quote('plg_%_mokosuite%'),
|
||||
$el . ' LIKE ' . $db->quote('plg_%_mokojoom%'),
|
||||
];
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('extension_id'),
|
||||
$db->quoteName('element'),
|
||||
$db->quoteName('name'),
|
||||
$db->quoteName('type'),
|
||||
$db->quoteName('folder'),
|
||||
$db->quoteName('enabled'),
|
||||
$db->quoteName('manifest_cache'),
|
||||
])
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where('('
|
||||
// The component
|
||||
. '(' . $db->quoteName('type') . ' = ' . $db->quote('component')
|
||||
. ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient') . ')'
|
||||
// Admin modules
|
||||
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('module')
|
||||
. ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mod_mokosuiteclient%') . ')'
|
||||
. ')')
|
||||
->where('(' . implode(' OR ', $patterns) . ')')
|
||||
->order($db->quoteName('type') . ' ASC, ' . $db->quoteName('element') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
@@ -248,12 +264,27 @@ class DashboardModel extends BaseDatabaseModel
|
||||
{
|
||||
$manifest = json_decode($row->manifest_cache ?? '{}');
|
||||
|
||||
// Determine product family from element name
|
||||
$family = 'mokosuite';
|
||||
if (stripos($row->element, 'mokosuiteclient') !== false) {
|
||||
$family = 'mokosuiteclient';
|
||||
} elseif (stripos($row->element, 'mokosuitehq') !== false) {
|
||||
$family = 'mokosuitehq';
|
||||
} elseif (stripos($row->element, 'mokosuitecrm') !== false) {
|
||||
$family = 'mokosuitecrm';
|
||||
} elseif (stripos($row->element, 'mokojoom') !== false) {
|
||||
$family = 'mokojoom';
|
||||
}
|
||||
|
||||
$extensions[] = (object) [
|
||||
'element' => $row->element,
|
||||
'name' => $manifest->name ?? $row->name,
|
||||
'type' => $row->type,
|
||||
'version' => $manifest->version ?? '',
|
||||
'enabled' => (int) $row->enabled,
|
||||
'extension_id' => (int) $row->extension_id,
|
||||
'element' => $row->element,
|
||||
'name' => $manifest->name ?? $row->name,
|
||||
'type' => $row->type,
|
||||
'folder' => $row->folder ?? '',
|
||||
'version' => $manifest->version ?? '',
|
||||
'enabled' => (int) $row->enabled,
|
||||
'family' => $family,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
$data = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
@@ -221,7 +221,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
$response = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
@@ -238,8 +238,15 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
return [];
|
||||
}
|
||||
|
||||
// Determine site's update channel preference
|
||||
$channel = 'dev'; // default to dev — show everything
|
||||
// Dev channel only available on Moko domains; all others forced to stable
|
||||
$isMokoDomain = (bool) preg_match('/\.mokoconsulting\.tech$/i', $_SERVER['HTTP_HOST'] ?? '');
|
||||
$channel = 'stable';
|
||||
if ($isMokoDomain) {
|
||||
try {
|
||||
$channel = \Joomla\CMS\Component\ComponentHelper::getParams('com_installer')
|
||||
->get('update_channel', 'stable') ?: 'stable';
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
$hasStable = false;
|
||||
$hasDev = false;
|
||||
|
||||
@@ -269,7 +276,18 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
$hasDev = true;
|
||||
}
|
||||
|
||||
if ($ver === '' || version_compare($ver, $bestVersion, '<='))
|
||||
if ($ver === '')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Respect update channel: stable channel skips dev-tagged versions
|
||||
if ($channel === 'stable' && $tag === 'dev')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (version_compare($ver, $bestVersion, '<='))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Filesystem\File;
|
||||
use Joomla\CMS\Filesystem\Folder;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
class AttachmentService
|
||||
{
|
||||
private const STORAGE_DIR = JPATH_ROOT . '/media/com_mokosuiteclient/attachments';
|
||||
|
||||
private const ALLOWED_EXTENSIONS = [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'rtf',
|
||||
'zip', 'gz', 'tar',
|
||||
];
|
||||
|
||||
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/**
|
||||
* Upload file(s) for a ticket or reply.
|
||||
*
|
||||
* @param int $ticketId Ticket ID
|
||||
* @param int|null $replyId Reply ID (null for ticket-level attachments)
|
||||
* @param array $files $_FILES array entry (single or multi)
|
||||
* @return array Saved attachment records
|
||||
*/
|
||||
public static function upload(int $ticketId, ?int $replyId, array $files): array
|
||||
{
|
||||
$saved = [];
|
||||
|
||||
// Normalize single file to array format
|
||||
if (!is_array($files['name'])) {
|
||||
$files = [
|
||||
'name' => [$files['name']],
|
||||
'type' => [$files['type']],
|
||||
'tmp_name' => [$files['tmp_name']],
|
||||
'error' => [$files['error']],
|
||||
'size' => [$files['size']],
|
||||
];
|
||||
}
|
||||
|
||||
$ticketDir = self::STORAGE_DIR . '/' . $ticketId;
|
||||
|
||||
if (!is_dir($ticketDir) && !Folder::create($ticketDir)) {
|
||||
Log::add("Failed to create attachment directory: {$ticketDir}", Log::ERROR, 'mokosuiteclient');
|
||||
return [];
|
||||
}
|
||||
|
||||
$userId = (int) Factory::getUser()->id;
|
||||
$db = Factory::getDbo();
|
||||
|
||||
for ($i = 0, $count = count($files['name']); $i < $count; $i++)
|
||||
{
|
||||
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
|
||||
Log::add("Attachment upload error for '{$files['name'][$i]}': PHP error code {$files['error'][$i]}", Log::WARNING, 'mokosuiteclient');
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalName = File::makeSafe($files['name'][$i]);
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
|
||||
// Validate extension
|
||||
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuiteclient');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if ($files['size'][$i] > self::MAX_FILE_SIZE) {
|
||||
Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuiteclient');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate unique filename to prevent overwrites
|
||||
$storedName = uniqid('att_', true) . '.' . $ext;
|
||||
$destPath = $ticketDir . '/' . $storedName;
|
||||
|
||||
if (!File::upload($files['tmp_name'][$i], $destPath)) {
|
||||
Log::add("Attachment upload failed: {$originalName}", Log::ERROR, 'mokosuiteclient');
|
||||
continue;
|
||||
}
|
||||
|
||||
$record = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'reply_id' => $replyId,
|
||||
'filename' => $originalName,
|
||||
'filepath' => $ticketId . '/' . $storedName,
|
||||
'filesize' => $files['size'][$i],
|
||||
'mimetype' => mime_content_type($destPath) ?: 'application/octet-stream',
|
||||
'uploaded_by' => $userId,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuiteclient_ticket_attachments', $record, 'id');
|
||||
$saved[] = $record;
|
||||
}
|
||||
|
||||
return $saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachments for a ticket.
|
||||
*/
|
||||
public static function getForTicket(int $ticketId): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('a.*, u.name AS uploader_name')
|
||||
->from($db->quoteName('#__mokosuiteclient_ticket_attachments', 'a'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by')
|
||||
->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId)
|
||||
->order('a.created ASC')
|
||||
);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the absolute filesystem path for an attachment.
|
||||
*/
|
||||
public static function getAbsolutePath(object $attachment): ?string
|
||||
{
|
||||
$path = realpath(self::STORAGE_DIR . '/' . $attachment->filepath);
|
||||
if ($path === false || !str_starts_with($path, realpath(self::STORAGE_DIR))) {
|
||||
return null;
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attachment (file + DB record).
|
||||
*/
|
||||
public static function delete(int $attachmentId): bool
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from('#__mokosuiteclient_ticket_attachments')
|
||||
->where('id = ' . $attachmentId)
|
||||
);
|
||||
$att = $db->loadObject();
|
||||
|
||||
if (!$att) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = self::STORAGE_DIR . '/' . $att->filepath;
|
||||
|
||||
if (file_exists($path)) {
|
||||
File::delete($path);
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete('#__mokosuiteclient_ticket_attachments')
|
||||
->where('id = ' . $attachmentId)
|
||||
)->execute();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display.
|
||||
*/
|
||||
public static function formatSize(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) return $bytes . ' B';
|
||||
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
|
||||
return round($bytes / 1048576, 1) . ' MB';
|
||||
}
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
/**
|
||||
* Automation rule engine — evaluates trigger/condition/action rules.
|
||||
*
|
||||
* Called from event hooks (system plugin, task plugin) whenever
|
||||
* a triggering event occurs. Loads matching rules, checks conditions,
|
||||
* and executes actions.
|
||||
*
|
||||
* @since 02.35.00
|
||||
*/
|
||||
class AutomationEngine
|
||||
{
|
||||
/**
|
||||
* Fire all matching rules for a given trigger event.
|
||||
*
|
||||
* @param string $triggerEvent Event name (ticket_created, user_login, etc.)
|
||||
* @param array $context Context data (ticket object, user data, etc.)
|
||||
*/
|
||||
public static function fire(string $triggerEvent, array $context = []): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$rules = self::getActiveRules($triggerEvent);
|
||||
|
||||
foreach ($rules as $rule)
|
||||
{
|
||||
$conditions = json_decode($rule->conditions, true) ?: [];
|
||||
$actions = json_decode($rule->actions, true) ?: [];
|
||||
|
||||
if (self::evaluateConditions($conditions, $context))
|
||||
{
|
||||
self::executeActions($actions, $rule, $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Automation engine error: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active automation rules for a trigger event.
|
||||
*/
|
||||
private static function getActiveRules(string $event): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from('#__mokosuiteclient_ticket_automation')
|
||||
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order('ordering ASC')
|
||||
);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all conditions (AND logic).
|
||||
*/
|
||||
private static function evaluateConditions(array $conditions, array $context): bool
|
||||
{
|
||||
foreach ($conditions as $c)
|
||||
{
|
||||
$field = $c['field'] ?? '';
|
||||
$op = $c['op'] ?? 'eq';
|
||||
$expected = $c['value'] ?? '';
|
||||
$actual = $context[$field] ?? '';
|
||||
|
||||
switch ($op)
|
||||
{
|
||||
case 'eq': if ((string) $actual !== (string) $expected) return false; break;
|
||||
case 'neq': if ((string) $actual === (string) $expected) return false; break;
|
||||
case 'gt': if ((float) $actual <= (float) $expected) return false; break;
|
||||
case 'lt': if ((float) $actual >= (float) $expected) return false; break;
|
||||
case 'in':
|
||||
$values = array_map('trim', explode(',', $expected));
|
||||
if (!in_array((string) $actual, $values, true)) return false;
|
||||
break;
|
||||
case 'not_in':
|
||||
$values = array_map('trim', explode(',', $expected));
|
||||
if (in_array((string) $actual, $values, true)) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute actions for a matched rule.
|
||||
*/
|
||||
private static function executeActions(array $actions, object $rule, array $context): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$ticketId = (int) ($context['ticket_id'] ?? $context['id'] ?? 0);
|
||||
|
||||
foreach ($actions as $action)
|
||||
{
|
||||
$type = $action['type'] ?? '';
|
||||
$value = $action['value'] ?? '';
|
||||
|
||||
try
|
||||
{
|
||||
switch ($type)
|
||||
{
|
||||
case 'set_status':
|
||||
if ($ticketId) {
|
||||
$statusId = self::resolveStatusId($db, $value);
|
||||
$sets = "status = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}";
|
||||
if ($statusId) { $sets .= ", status_id = {$statusId}"; }
|
||||
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'set_priority':
|
||||
if ($ticketId) {
|
||||
$priorityId = self::resolvePriorityId($db, $value);
|
||||
$sets = "priority = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}";
|
||||
if ($priorityId) { $sets .= ", priority_id = {$priorityId}"; }
|
||||
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'assign':
|
||||
$assignId = (int) $value;
|
||||
if ($ticketId && $assignId > 0) {
|
||||
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET assigned_to = {$assignId}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'add_note':
|
||||
if ($ticketId) {
|
||||
$note = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'user_id' => 0,
|
||||
'body' => $value ?: '[Automation: ' . ($rule->title ?? '') . ']',
|
||||
'is_internal' => 1,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokosuiteclient_ticket_replies', $note);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'send_email':
|
||||
NotificationService::securityAlert(
|
||||
'automation',
|
||||
'Automation: ' . ($rule->title ?? ''),
|
||||
$value ?: 'Rule triggered for ticket #' . $ticketId
|
||||
);
|
||||
break;
|
||||
|
||||
case 'send_ntfy':
|
||||
NotificationService::pushNtfySecurity(
|
||||
'automation',
|
||||
'Automation: ' . ($rule->title ?? ''),
|
||||
$value ?: 'Rule triggered for ticket #' . $ticketId
|
||||
);
|
||||
break;
|
||||
|
||||
case 'close':
|
||||
if ($ticketId) {
|
||||
$closedId = self::resolveClosedStatusId($db);
|
||||
$sets = "status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}, modified = {$db->quote(Factory::getDate()->toSql())}";
|
||||
if ($closedId) { $sets .= ", status_id = {$closedId}"; }
|
||||
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'create_ticket':
|
||||
self::createTicketFromAutomation($rule, $context, $value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add("Automation action '{$type}' failed for rule #{$rule->id}: " . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ticket from automation (with behavior: append/always_new/skip_if_open).
|
||||
*/
|
||||
private static function resolveStatusId($db, string $alias): int
|
||||
{
|
||||
return (int) $db->setQuery(
|
||||
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1
|
||||
)->loadResult();
|
||||
}
|
||||
|
||||
private static function resolvePriorityId($db, string $alias): int
|
||||
{
|
||||
return (int) $db->setQuery(
|
||||
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1
|
||||
)->loadResult();
|
||||
}
|
||||
|
||||
private static function resolveClosedStatusId($db): int
|
||||
{
|
||||
return (int) $db->setQuery(
|
||||
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||
->where($db->quoteName('is_closed') . ' = 1'), 0, 1
|
||||
)->loadResult();
|
||||
}
|
||||
|
||||
private static function createTicketFromAutomation(object $rule, array $context, string $subject): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$behavior = $rule->behavior ?? 'append';
|
||||
$userId = (int) ($context['user_id'] ?? 0);
|
||||
$catId = (int) ($context['category_id'] ?? 0);
|
||||
|
||||
if ($behavior !== 'always_new' && $userId > 0)
|
||||
{
|
||||
// Check for existing open ticket (check both status ENUM and status_id)
|
||||
$query = $db->getQuery(true)
|
||||
->select('t.id')
|
||||
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON t.status_id = s.id')
|
||||
->where('t.created_by = ' . $userId)
|
||||
->where("(s.id IS NULL AND t.status NOT IN ('closed', 'resolved')) OR (s.id IS NOT NULL AND s.is_closed = 0)");
|
||||
|
||||
if ($catId > 0) {
|
||||
$query->where('category_id = ' . $catId);
|
||||
}
|
||||
|
||||
$db->setQuery($query, 0, 1);
|
||||
$existingId = (int) $db->loadResult();
|
||||
|
||||
if ($existingId > 0)
|
||||
{
|
||||
if ($behavior === 'skip_if_open') return;
|
||||
|
||||
// append — add reply to existing ticket
|
||||
$reply = (object) [
|
||||
'ticket_id' => $existingId,
|
||||
'user_id' => 0,
|
||||
'body' => $subject ?: '[Automation: ' . ($rule->title ?? '') . ']',
|
||||
'is_internal' => 1,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new ticket
|
||||
$openStatusId = self::resolveStatusId($db, 'open') ?: null;
|
||||
$normalPriorityId = self::resolvePriorityId($db, $context['priority'] ?? 'normal') ?: null;
|
||||
$ticket = (object) [
|
||||
'subject' => $subject ?: 'Automation: ' . ($rule->title ?? ''),
|
||||
'body' => $context['body'] ?? '',
|
||||
'status' => 'open',
|
||||
'status_id' => $openStatusId,
|
||||
'priority' => $context['priority'] ?? 'normal',
|
||||
'priority_id' => $normalPriorityId,
|
||||
'category_id' => $catId ?: null,
|
||||
'created_by' => $userId,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
|
||||
}
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
/**
|
||||
* Helpdesk email notification service.
|
||||
*
|
||||
* Sends emails for ticket events to Joomla users (by ID) and/or
|
||||
* raw email addresses. Uses Joomla's configured mailer.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class NotificationService
|
||||
{
|
||||
/**
|
||||
* Send a ticket notification email.
|
||||
*
|
||||
* @param string $event Event name (ticket_created, ticket_replied, status_changed, ticket_assigned)
|
||||
* @param object $ticket Ticket object with id, subject, status, priority, created_by, assigned_to
|
||||
* @param array $extra Extra context (reply body, old status, etc.)
|
||||
*/
|
||||
public static function notify(string $event, object $ticket, array $extra = []): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$recipients = self::getRecipients($event, $ticket);
|
||||
|
||||
if (empty($recipients))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$subject = self::buildSubject($event, $ticket);
|
||||
$body = self::buildBody($event, $ticket, $extra);
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->isHtml(false);
|
||||
$mailer->setSubject($subject);
|
||||
$mailer->setBody($body);
|
||||
|
||||
foreach ($recipients as $email)
|
||||
{
|
||||
$email = trim($email);
|
||||
|
||||
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$mailer->clearAddresses();
|
||||
$mailer->addRecipient($email);
|
||||
$mailer->Send();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
// Push notification via ntfy
|
||||
self::pushNtfy($event, $ticket, $subject);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine recipients based on event type and ticket data.
|
||||
*/
|
||||
private static function getRecipients(string $event, object $ticket): array
|
||||
{
|
||||
$emails = [];
|
||||
|
||||
// Get notification config from component params
|
||||
$config = self::getNotificationConfig();
|
||||
|
||||
// Always notify configured admin emails
|
||||
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
|
||||
$emails = array_merge($emails, $adminEmails);
|
||||
|
||||
// Always notify configured admin user IDs
|
||||
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
|
||||
|
||||
foreach ($adminUserIds as $uid)
|
||||
{
|
||||
$email = self::getUserEmail($uid);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
switch ($event)
|
||||
{
|
||||
case 'ticket_created':
|
||||
// Notify assigned user if any
|
||||
if (!empty($ticket->assigned_to))
|
||||
{
|
||||
$email = self::getUserEmail((int) $ticket->assigned_to);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ticket_replied':
|
||||
// Notify ticket creator (customer gets notified of staff reply)
|
||||
if (!empty($ticket->created_by))
|
||||
{
|
||||
$email = self::getUserEmail((int) $ticket->created_by);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify assigned user
|
||||
if (!empty($ticket->assigned_to))
|
||||
{
|
||||
$email = self::getUserEmail((int) $ticket->assigned_to);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'status_changed':
|
||||
// Notify ticket creator
|
||||
if (!empty($ticket->created_by))
|
||||
{
|
||||
$email = self::getUserEmail((int) $ticket->created_by);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ticket_assigned':
|
||||
// Notify newly assigned user
|
||||
if (!empty($ticket->assigned_to))
|
||||
{
|
||||
$email = self::getUserEmail((int) $ticket->assigned_to);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return array_unique($emails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build email subject line.
|
||||
*/
|
||||
private static function buildSubject(string $event, object $ticket): string
|
||||
{
|
||||
$siteName = Factory::getConfig()->get('sitename', 'Support');
|
||||
$prefix = '[' . $siteName . ' #' . $ticket->id . '] ';
|
||||
|
||||
switch ($event)
|
||||
{
|
||||
case 'ticket_created':
|
||||
return $prefix . 'New Ticket: ' . ($ticket->subject ?? '');
|
||||
|
||||
case 'ticket_replied':
|
||||
return $prefix . 'Reply: ' . ($ticket->subject ?? '');
|
||||
|
||||
case 'status_changed':
|
||||
return $prefix . 'Status Changed: ' . ($ticket->subject ?? '');
|
||||
|
||||
case 'ticket_assigned':
|
||||
return $prefix . 'Assigned: ' . ($ticket->subject ?? '');
|
||||
|
||||
default:
|
||||
return $prefix . ($ticket->subject ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build email body.
|
||||
*/
|
||||
private static function buildBody(string $event, object $ticket, array $extra): string
|
||||
{
|
||||
$siteName = Factory::getConfig()->get('sitename', 'Support');
|
||||
$siteUrl = rtrim(Uri::root(), '/');
|
||||
$ticketUrl = $siteUrl . '/index.php?option=com_mokosuiteclient&view=ticket&id=' . $ticket->id;
|
||||
|
||||
$lines = [];
|
||||
$lines[] = $siteName . ' Support';
|
||||
$lines[] = str_repeat('-', 40);
|
||||
$lines[] = '';
|
||||
|
||||
switch ($event)
|
||||
{
|
||||
case 'ticket_created':
|
||||
$lines[] = 'A new support ticket has been created.';
|
||||
$lines[] = '';
|
||||
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
|
||||
$lines[] = 'Category: ' . ($ticket->category_title ?? 'General');
|
||||
$lines[] = '';
|
||||
|
||||
if (!empty($ticket->body))
|
||||
{
|
||||
$lines[] = 'Description:';
|
||||
$lines[] = strip_tags($ticket->body);
|
||||
$lines[] = '';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ticket_replied':
|
||||
$lines[] = 'A new reply has been added to your ticket.';
|
||||
$lines[] = '';
|
||||
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||
$lines[] = 'Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
||||
$lines[] = '';
|
||||
|
||||
if (!empty($extra['reply_body']))
|
||||
{
|
||||
$lines[] = 'Reply:';
|
||||
$lines[] = strip_tags($extra['reply_body']);
|
||||
$lines[] = '';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'status_changed':
|
||||
$lines[] = 'Your ticket status has been updated.';
|
||||
$lines[] = '';
|
||||
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||
$lines[] = 'New Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
||||
|
||||
if (!empty($extra['old_status']))
|
||||
{
|
||||
$lines[] = 'Old Status: ' . ucwords(str_replace('_', ' ', $extra['old_status']));
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
break;
|
||||
|
||||
case 'ticket_assigned':
|
||||
$lines[] = 'A ticket has been assigned to you.';
|
||||
$lines[] = '';
|
||||
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
|
||||
$lines[] = '';
|
||||
break;
|
||||
}
|
||||
|
||||
$lines[] = 'View ticket: ' . $ticketUrl;
|
||||
$lines[] = '';
|
||||
$lines[] = '-- ';
|
||||
$lines[] = $siteName . ' | Powered by MokoSuiteClient';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email address for a Joomla user ID.
|
||||
*/
|
||||
private static function getUserEmail(int $userId): ?string
|
||||
{
|
||||
if ($userId <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('email'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . $userId)
|
||||
);
|
||||
|
||||
return $db->loadResult() ?: null;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Failed to look up email for user ID ' . $userId . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification configuration from component params.
|
||||
*/
|
||||
private static function getNotificationConfig(): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
);
|
||||
|
||||
$params = json_decode($db->loadResult() ?? '{}', true);
|
||||
|
||||
return $params['notifications'] ?? [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Failed to load notification config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Ntfy Push Notifications (#205)
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Send a push notification via ntfy for ticket events.
|
||||
*/
|
||||
private static function pushNtfy(string $event, object $ticket, string $title): void
|
||||
{
|
||||
$config = self::getNotificationConfig();
|
||||
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
|
||||
|
||||
if (!$ntfyEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
|
||||
$ntfyTopic = $config['ntfy_topic'] ?? 'mokosuiteclient-tickets';
|
||||
$ntfyToken = $config['ntfy_token'] ?? '';
|
||||
|
||||
$tagMap = [
|
||||
'ticket_created' => 'ticket,new',
|
||||
'ticket_replied' => 'speech_balloon',
|
||||
'status_changed' => 'arrows_counterclockwise',
|
||||
'ticket_assigned' => 'bust_in_silhouette',
|
||||
];
|
||||
|
||||
$priorityMap = [
|
||||
'ticket_created' => '4',
|
||||
'ticket_replied' => '3',
|
||||
'status_changed' => '3',
|
||||
'ticket_assigned' => '3',
|
||||
];
|
||||
|
||||
$siteUrl = rtrim(Uri::root(), '/');
|
||||
$ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuiteclient&view=ticket&id=' . ($ticket->id ?? 0);
|
||||
|
||||
$message = self::buildNtfyMessage($event, $ticket);
|
||||
|
||||
$headers = [
|
||||
'Title: ' . $title,
|
||||
'Priority: ' . ($priorityMap[$event] ?? '3'),
|
||||
'Tags: ' . ($tagMap[$event] ?? 'ticket'),
|
||||
'Click: ' . $ticketUrl,
|
||||
];
|
||||
|
||||
if ($ntfyToken !== '')
|
||||
{
|
||||
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
|
||||
}
|
||||
|
||||
$url = $ntfyServer . '/' . $ntfyTopic;
|
||||
|
||||
try
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false)
|
||||
{
|
||||
Log::add("Ntfy push connection failed for event {$event}: " . $curlError, Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
elseif ($httpCode < 200 || $httpCode >= 300)
|
||||
{
|
||||
Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a short ntfy message body for ticket events.
|
||||
*/
|
||||
private static function buildNtfyMessage(string $event, object $ticket): string
|
||||
{
|
||||
$subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?');
|
||||
|
||||
switch ($event)
|
||||
{
|
||||
case 'ticket_created':
|
||||
$priority = ucfirst($ticket->priority ?? 'normal');
|
||||
return "New ticket: {$subject}\nPriority: {$priority}";
|
||||
|
||||
case 'ticket_replied':
|
||||
return "Reply on: {$subject}";
|
||||
|
||||
case 'status_changed':
|
||||
$status = ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
||||
return "Status → {$status}: {$subject}";
|
||||
|
||||
case 'ticket_assigned':
|
||||
return "Assigned to you: {$subject}";
|
||||
|
||||
default:
|
||||
return $subject;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a push notification via ntfy for security events.
|
||||
*/
|
||||
public static function pushNtfySecurity(string $event, string $title, string $body): void
|
||||
{
|
||||
$config = self::getNotificationConfig();
|
||||
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
|
||||
|
||||
if (!$ntfyEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
|
||||
$ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuiteclient-security';
|
||||
$ntfyToken = $config['ntfy_token'] ?? '';
|
||||
|
||||
$headers = [
|
||||
'Title: [Security] ' . $title,
|
||||
'Priority: 5',
|
||||
'Tags: warning,shield',
|
||||
];
|
||||
|
||||
if ($ntfyToken !== '')
|
||||
{
|
||||
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
|
||||
}
|
||||
|
||||
$url = $ntfyServer . '/' . $ntfyTopic;
|
||||
|
||||
try
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Security Event Notifications (#131)
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Send a security alert to admin emails.
|
||||
*/
|
||||
public static function securityAlert(string $event, string $subject, string $body): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$config = self::getNotificationConfig();
|
||||
$enabled = $config['security_alerts'] ?? '1';
|
||||
|
||||
if (!$enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
|
||||
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
|
||||
|
||||
$recipients = $adminEmails;
|
||||
|
||||
foreach ($adminUserIds as $uid)
|
||||
{
|
||||
$email = self::getUserEmail($uid);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$recipients[] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
$recipients = array_unique($recipients);
|
||||
|
||||
if (empty($recipients))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$siteName = Factory::getConfig()->get('sitename', 'Site');
|
||||
$fullSubject = '[' . $siteName . ' Security] ' . $subject;
|
||||
|
||||
$lines = [
|
||||
$siteName . ' Security Alert',
|
||||
str_repeat('-', 40),
|
||||
'',
|
||||
'Event: ' . $event,
|
||||
'Time: ' . gmdate('Y-m-d H:i:s') . ' UTC',
|
||||
'',
|
||||
$body,
|
||||
'',
|
||||
'-- ',
|
||||
$siteName . ' | MokoSuiteClient Security',
|
||||
];
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->isHtml(false);
|
||||
$mailer->setSubject($fullSubject);
|
||||
$mailer->setBody(implode("\n", $lines));
|
||||
|
||||
foreach ($recipients as $email)
|
||||
{
|
||||
try
|
||||
{
|
||||
$mailer->clearAddresses();
|
||||
$mailer->addRecipient(trim($email));
|
||||
$mailer->Send();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
// Also push via ntfy
|
||||
self::pushNtfySecurity($event, $subject, $body);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\View\Canned;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $responses = [];
|
||||
protected $categories = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
|
||||
$db->setQuery('SELECT * FROM #__mokosuiteclient_ticket_canned ORDER BY ordering ASC');
|
||||
$this->responses = $db->loadObjectList() ?: [];
|
||||
|
||||
$db->setQuery('SELECT id, title FROM #__mokosuiteclient_ticket_categories WHERE published = 1 ORDER BY ordering');
|
||||
$this->categories = $db->loadObjectList() ?: [];
|
||||
|
||||
ToolbarHelper::title('Canned Responses', 'comment');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class HtmlView extends BaseHtmlView
|
||||
protected $loginChartData = [];
|
||||
protected $mokoExtensions = [];
|
||||
public $supportPin = '';
|
||||
public $supportPinAvailable = false;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
@@ -47,12 +48,21 @@ class HtmlView extends BaseHtmlView
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
);
|
||||
$token = (json_decode((string) $db->loadResult()))->health_api_token ?? '';
|
||||
$coreParams = json_decode((string) $db->loadResult());
|
||||
$healthToken = $coreParams->health_api_token ?? '';
|
||||
$this->supportPinAvailable = !empty($healthToken);
|
||||
|
||||
if (!empty($token))
|
||||
if (!empty($healthToken))
|
||||
{
|
||||
$hash = hash_hmac('sha256', gmdate('Y-m-d'), $token);
|
||||
$this->supportPin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
|
||||
$pinRequestedAt = $coreParams->support_pin_requested_at ?? '';
|
||||
$pinTtl = 72 * 3600;
|
||||
|
||||
if (!empty($pinRequestedAt) && (time() - (int) $pinRequestedAt) < $pinTtl)
|
||||
{
|
||||
$window = floor((int) $pinRequestedAt / $pinTtl);
|
||||
$hash = hash_hmac('sha256', (string) $window, $healthToken);
|
||||
$this->supportPin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$responses = $this->responses;
|
||||
$categories = $this->categories;
|
||||
$token = Session::getFormToken();
|
||||
$saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveCanned&format=json');
|
||||
$deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteCanned&format=json');
|
||||
$reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorderCanned&format=json');
|
||||
|
||||
// Build category map for filter display
|
||||
$catMap = [0 => 'All Categories'];
|
||||
foreach ($categories as $cat)
|
||||
{
|
||||
$catMap[$cat->id] = $cat->title;
|
||||
}
|
||||
?>
|
||||
|
||||
<div id="mokosuiteclient-canned">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<h4 class="mb-0"><?php echo count($responses); ?> Canned Responses</h4>
|
||||
<select id="canned-filter-category" class="form-select form-select-sm" style="width:auto;">
|
||||
<option value="">All Categories</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="openCannedModal(0)">
|
||||
<span class="icon-plus"></span> Add Response
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="canned-list">
|
||||
<?php foreach ($responses as $r): ?>
|
||||
<div class="card mb-2 canned-card" data-id="<?php echo $r->id; ?>" data-category="<?php echo (int) $r->category_id; ?>" style="cursor:grab;">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1" style="cursor:pointer;" onclick="openCannedModal(<?php echo $r->id; ?>)">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="icon-menu text-muted" style="cursor:grab;" title="Drag to reorder"></span>
|
||||
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
||||
<?php if (!empty($r->category_id) && isset($catMap[$r->category_id])): ?>
|
||||
<span class="badge bg-secondary"><?php echo htmlspecialchars($catMap[$r->category_id]); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="text-muted small mb-0 mt-1 ms-4"><?php echo htmlspecialchars(mb_substr(strip_tags($r->body), 0, 150)); ?></p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
|
||||
<span class="icon-trash"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($responses)): ?>
|
||||
<div class="alert alert-info" id="canned-empty">No canned responses yet. Click "Add Response" to create one.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canned Response Modal (create + edit) -->
|
||||
<div class="modal fade" id="cannedModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 id="cannedModalTitle">Add Canned Response</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="canned-id" value="0">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" id="canned-title" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Category (optional)</label>
|
||||
<select id="canned-category" class="form-select">
|
||||
<option value="">No category</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Response Text</label>
|
||||
<textarea id="canned-body" class="form-control" rows="8" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-save-canned"><span class="icon-save"></span> Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var tokenKey = '<?php echo $token; ?>';
|
||||
|
||||
// ── Response data store (for edit modal) ────────────────────
|
||||
var responseData = {};
|
||||
<?php foreach ($responses as $r): ?>
|
||||
responseData[<?php echo $r->id; ?>] = {
|
||||
title: <?php echo json_encode($r->title); ?>,
|
||||
body: <?php echo json_encode($r->body); ?>,
|
||||
category_id: <?php echo json_encode($r->category_id ?? ''); ?>
|
||||
};
|
||||
<?php endforeach; ?>
|
||||
|
||||
// ── Open modal for create (id=0) or edit ────────────────────
|
||||
window.openCannedModal = function(id) {
|
||||
document.getElementById('canned-id').value = id;
|
||||
if (id > 0 && responseData[id]) {
|
||||
document.getElementById('cannedModalTitle').textContent = 'Edit Canned Response';
|
||||
document.getElementById('canned-title').value = responseData[id].title;
|
||||
document.getElementById('canned-body').value = responseData[id].body;
|
||||
document.getElementById('canned-category').value = responseData[id].category_id || '';
|
||||
} else {
|
||||
document.getElementById('cannedModalTitle').textContent = 'Add Canned Response';
|
||||
document.getElementById('canned-title').value = '';
|
||||
document.getElementById('canned-body').value = '';
|
||||
document.getElementById('canned-category').value = '';
|
||||
}
|
||||
new bootstrap.Modal(document.getElementById('cannedModal')).show();
|
||||
};
|
||||
|
||||
// ── Save (create or update) ─────────────────────────────────
|
||||
document.getElementById('btn-save-canned').addEventListener('click', function() {
|
||||
var title = document.getElementById('canned-title').value.trim();
|
||||
if (!title) { Joomla.renderMessages({error:['Title is required']}); return; }
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('id', document.getElementById('canned-id').value);
|
||||
fd.append('title', title);
|
||||
fd.append('body', document.getElementById('canned-body').value);
|
||||
fd.append('category_id', document.getElementById('canned-category').value);
|
||||
fd.append(tokenKey, '1');
|
||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else Joomla.renderMessages({error:[d.message]});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────
|
||||
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Delete this canned response?')) return;
|
||||
var card = this.closest('.card');
|
||||
var fd = new FormData();
|
||||
fd.append('id', this.dataset.id);
|
||||
fd.append(tokenKey, '1');
|
||||
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Category filter ─────────────────────────────────────────
|
||||
document.getElementById('canned-filter-category').addEventListener('change', function() {
|
||||
var catId = this.value;
|
||||
document.querySelectorAll('.canned-card').forEach(function(card) {
|
||||
if (!catId || card.dataset.category === catId) {
|
||||
card.style.display = '';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Drag-and-drop reorder ───────────────────────────────────
|
||||
var list = document.getElementById('canned-list');
|
||||
var dragCard = null;
|
||||
|
||||
list.addEventListener('dragstart', function(e) {
|
||||
dragCard = e.target.closest('.canned-card');
|
||||
if (dragCard) {
|
||||
dragCard.style.opacity = '0.5';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener('dragend', function() {
|
||||
if (dragCard) dragCard.style.opacity = '';
|
||||
dragCard = null;
|
||||
});
|
||||
|
||||
list.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
var target = e.target.closest('.canned-card');
|
||||
if (target && target !== dragCard) {
|
||||
var rect = target.getBoundingClientRect();
|
||||
var after = (e.clientY - rect.top) > rect.height / 2;
|
||||
if (after) {
|
||||
target.parentNode.insertBefore(dragCard, target.nextSibling);
|
||||
} else {
|
||||
target.parentNode.insertBefore(dragCard, target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
// Persist new order
|
||||
var ids = [];
|
||||
document.querySelectorAll('.canned-card').forEach(function(c) { ids.push(c.dataset.id); });
|
||||
var fd = new FormData();
|
||||
fd.append('order', JSON.stringify(ids));
|
||||
fd.append(tokenKey, '1');
|
||||
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||
});
|
||||
|
||||
// Make cards draggable
|
||||
document.querySelectorAll('.canned-card').forEach(function(card) {
|
||||
card.setAttribute('draggable', 'true');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -25,6 +25,9 @@ $atsAvail = $this->atsAvailable ?? null;
|
||||
$checkedOut = $this->checkedOutItems;
|
||||
$wafBlocks = $this->wafBlocks;
|
||||
$token = Session::getFormToken();
|
||||
$user = \Joomla\CMS\Factory::getApplication()->getIdentity();
|
||||
$canWafLog = $user->authorise('mokosuiteclient.security.waflog', 'com_mokosuiteclient')
|
||||
|| $user->authorise('core.admin', 'com_mokosuiteclient');
|
||||
|
||||
// Group plugins by category
|
||||
$grouped = [];
|
||||
@@ -48,77 +51,43 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<!-- Site Info Bar -->
|
||||
<div class="mokosuiteclient-info-bar card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="mokosuiteclient-info-item">
|
||||
<span class="mokosuiteclient-info-label"><?php echo Text::_('COM_MOKOSUITECLIENT_SITE'); ?></span>
|
||||
<span class="mokosuiteclient-info-value fw-bold"><?php echo $this->escape($siteInfo->sitename); ?></span>
|
||||
</div>
|
||||
<div class="mokosuiteclient-info-item">
|
||||
<span class="mokosuiteclient-info-label">MokoSuite</span>
|
||||
<span class="mokosuiteclient-info-value"><span class="badge bg-primary"><?php echo $this->escape($siteInfo->mokosuiteclient_version); ?></span></span>
|
||||
</div>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body d-flex flex-wrap align-items-center gap-2" style="padding:0.75rem 1.25rem;font-size:0.85rem;">
|
||||
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.1rem;color:#1a2744"></span>
|
||||
<span class="fw-bold"><?php echo $this->escape($siteInfo->sitename); ?></span>
|
||||
<span class="badge bg-primary">MokoSuite <?php echo $this->escape($siteInfo->mokosuiteclient_version); ?></span>
|
||||
<?php if (!empty($this->supportPin)): ?>
|
||||
<div class="mokosuiteclient-info-item">
|
||||
<span class="mokosuiteclient-info-label">Support PIN</span>
|
||||
<span class="mokosuiteclient-info-value"><span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="Daily verification PIN — rotates at midnight UTC."><span class="icon-key small me-1" aria-hidden="true"></span><?php echo $this->escape($this->supportPin); ?></span></span>
|
||||
</div>
|
||||
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="Support PIN — valid for 72 hours"><span class="icon-key small me-1" aria-hidden="true"></span><?php echo $this->escape($this->supportPin); ?></span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-1" id="mokosuiteclient-btn-heartbeat-pin"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.sendHeartbeat&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>"
|
||||
title="Send heartbeat with PIN to MokoSuiteHQ">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
</button>
|
||||
<?php elseif (!empty($this->supportPinAvailable)): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark py-0 px-2" id="mokosuiteclient-request-pin"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.requestPin&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>"
|
||||
style="font-size:0.75rem;" title="Request a support PIN (valid 72 hours)">
|
||||
<span class="icon-key" aria-hidden="true"></span> Request PIN
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<div class="mokosuiteclient-info-item">
|
||||
<span class="mokosuiteclient-info-label">Joomla</span>
|
||||
<span class="mokosuiteclient-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->joomla_version); ?></span></span>
|
||||
</div>
|
||||
<div class="mokosuiteclient-info-item">
|
||||
<span class="mokosuiteclient-info-label">PHP</span>
|
||||
<span class="mokosuiteclient-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->php_version); ?></span></span>
|
||||
</div>
|
||||
<div class="mokosuiteclient-info-item">
|
||||
<span class="mokosuiteclient-info-label"><?php echo Text::_('COM_MOKOSUITECLIENT_DATABASE'); ?></span>
|
||||
<span class="mokosuiteclient-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->db_type); ?></span></span>
|
||||
</div>
|
||||
<span class="badge bg-secondary">Joomla <?php echo $this->escape($siteInfo->joomla_version); ?></span>
|
||||
<span class="badge bg-secondary">PHP <?php echo $this->escape($siteInfo->php_version); ?></span>
|
||||
<span class="badge bg-secondary"><?php echo $this->escape($siteInfo->db_type); ?></span>
|
||||
<?php if ($siteInfo->debug): ?>
|
||||
<div class="mokosuiteclient-info-item">
|
||||
<span class="mokosuiteclient-info-value"><span class="badge bg-warning text-dark"><?php echo Text::_('COM_MOKOSUITECLIENT_DEBUG_ON'); ?></span></span>
|
||||
</div>
|
||||
<span class="badge bg-warning text-dark">Debug ON</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($siteInfo->offline): ?>
|
||||
<div class="mokosuiteclient-info-item">
|
||||
<span class="mokosuiteclient-info-value"><span class="badge bg-danger"><?php echo Text::_('COM_MOKOSUITECLIENT_OFFLINE'); ?></span></span>
|
||||
</div>
|
||||
<span class="badge bg-danger">Offline</span>
|
||||
<?php endif; ?>
|
||||
<div class="mokosuiteclient-info-item ms-auto">
|
||||
<span class="ms-auto d-flex align-items-center gap-2">
|
||||
<span class="icon-globe" aria-hidden="true"></span>
|
||||
<code><?php echo $this->escape($_SERVER['REMOTE_ADDR'] ?? ''); ?></code>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($mokoExts)): ?>
|
||||
<!-- Moko Component & Module Versions -->
|
||||
<div class="row g-0 mb-4 border rounded overflow-hidden">
|
||||
<?php
|
||||
$extIcons = [
|
||||
'com_mokosuiteclient' => 'icon-cogs',
|
||||
'mod_mokosuiteclient_cpanel' => 'icon-tachometer-alt',
|
||||
'mod_mokosuiteclient_menu' => 'icon-bars',
|
||||
'mod_mokosuiteclient_cache' => 'icon-bolt',
|
||||
'mod_mokosuiteclient_categories' => 'icon-folder',
|
||||
];
|
||||
$extCount = count($mokoExts);
|
||||
$colClass = $extCount > 0 ? 'col-' . max(1, (int) floor(12 / $extCount)) : 'col';
|
||||
foreach ($mokoExts as $ext):
|
||||
$icon = $extIcons[$ext->element] ?? 'icon-puzzle-piece';
|
||||
$label = str_replace(['mod_mokosuiteclient_', 'com_mokosuiteclient'], ['', 'Component'], $ext->element);
|
||||
$label = ucfirst($label ?: 'Component');
|
||||
?>
|
||||
<div class="<?php echo $colClass; ?> d-flex align-items-center justify-content-center gap-2 py-2 bg-white border-end" style="font-size:0.82rem;">
|
||||
<span class="<?php echo $icon; ?>" aria-hidden="true" style="color:#1a2744;"></span>
|
||||
<span><?php echo $this->escape($label); ?></span>
|
||||
<span class="badge bg-light text-dark"><?php echo $this->escape($ext->version); ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($adminToolsAvail || $atsAvail): ?>
|
||||
<!-- Akeeba Import Banner -->
|
||||
@@ -219,7 +188,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
||||
</h3>
|
||||
<div class="mokosuiteclient-plugin-grid row g-3 mb-4">
|
||||
<?php foreach ($catPlugins as $plugin): ?>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12 <?php echo $catKey === 'core' ? '' : 'col-md-6 col-lg-4'; ?>">
|
||||
<div class="card mokosuiteclient-plugin-card h-100 <?php echo $plugin->enabled ? '' : 'mokosuiteclient-plugin-disabled'; ?>"
|
||||
data-extension-id="<?php echo $plugin->extension_id; ?>">
|
||||
<div class="card-body d-flex flex-column">
|
||||
@@ -236,11 +205,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
||||
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
|
||||
<?php if ($plugin->protected): ?>
|
||||
<span class="badge bg-dark"><?php echo Text::_('COM_MOKOSUITECLIENT_PROTECTED'); ?></span>
|
||||
<?php elseif ($plugin->configure_only): ?>
|
||||
<span class="badge bg-<?php echo $plugin->enabled ? 'success' : 'secondary'; ?>">
|
||||
<?php echo $plugin->enabled ? Text::_('COM_MOKOSUITECLIENT_ENABLED') : Text::_('COM_MOKOSUITECLIENT_DISABLED'); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<?php elseif ($plugin->extension_id): ?>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input mokosuiteclient-toggle" role="switch"
|
||||
id="toggle-<?php echo $plugin->extension_id; ?>"
|
||||
@@ -253,7 +218,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
||||
</label>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($plugin->type === 'plugin'): ?>
|
||||
<?php if ($plugin->extension_id && $plugin->type === 'plugin'): ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . $plugin->extension_id); ?>" class="btn btn-sm btn-outline-secondary">
|
||||
<span class="icon-cog" aria-hidden="true"></span> <?php echo Text::_('COM_MOKOSUITECLIENT_CONFIGURE'); ?>
|
||||
</a>
|
||||
@@ -270,6 +235,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
||||
<!-- Right: Charts & Information (4 cols) -->
|
||||
<div class="col-12 col-xl-4" style="border-left:1px solid var(--gray-300, #dee2e6);padding-left:1.5rem;">
|
||||
|
||||
<?php if ($canWafLog): ?>
|
||||
<!-- WAF Activity Chart -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
@@ -279,6 +245,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
||||
<canvas id="mokosuiteclient-chart-waf" height="140"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Login Activity Chart -->
|
||||
<div class="card mb-3">
|
||||
@@ -349,6 +316,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($canWafLog): ?>
|
||||
<!-- WAF Blocks -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
@@ -376,6 +344,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Recent Logins -->
|
||||
<div class="card mb-3">
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
/**
|
||||
* Helpdesk Tickets REST API controller.
|
||||
*
|
||||
* GET /api/index.php/v1/mokosuiteclient/tickets - list tickets
|
||||
* GET /api/index.php/v1/mokosuiteclient/tickets/{id} - get single ticket with replies
|
||||
* POST /api/index.php/v1/mokosuiteclient/tickets - create ticket
|
||||
* PATCH /api/index.php/v1/mokosuiteclient/tickets/{id} - update ticket fields
|
||||
* POST /api/index.php/v1/mokosuiteclient/tickets/{id}/reply - add reply
|
||||
*
|
||||
* @since 02.35.00
|
||||
*/
|
||||
class TicketsController extends BaseController
|
||||
{
|
||||
/**
|
||||
* GET /tickets — list tickets with optional filters.
|
||||
*/
|
||||
public function displayList(): void
|
||||
{
|
||||
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$db = Factory::getDbo();
|
||||
$input = $app->getInput();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
|
||||
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->order('t.created DESC');
|
||||
|
||||
// Filters
|
||||
$status = $input->getString('status', '');
|
||||
if ($status) {
|
||||
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($status));
|
||||
}
|
||||
|
||||
$categoryId = $input->getInt('category_id', 0);
|
||||
if ($categoryId) {
|
||||
$query->where($db->quoteName('t.category_id') . ' = ' . $categoryId);
|
||||
}
|
||||
|
||||
$assignedTo = $input->getInt('assigned_to', 0);
|
||||
if ($assignedTo) {
|
||||
$query->where($db->quoteName('t.assigned_to') . ' = ' . $assignedTo);
|
||||
}
|
||||
|
||||
$limit = min($input->getInt('limit', 25), 100);
|
||||
$offset = $input->getInt('offset', 0);
|
||||
$db->setQuery($query, $offset, $limit);
|
||||
|
||||
$tickets = $db->loadObjectList() ?: [];
|
||||
|
||||
// Total count (with same filters applied)
|
||||
$countQuery = clone $query;
|
||||
$countQuery->clear('select')->clear('order')->select('COUNT(*)');
|
||||
$db->setQuery($countQuery);
|
||||
$total = (int) $db->loadResult();
|
||||
|
||||
$this->sendJson(200, [
|
||||
'tickets' => $tickets,
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /tickets/{id} — single ticket with replies and attachments.
|
||||
*/
|
||||
public function displayItem(): void
|
||||
{
|
||||
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Ticket
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
|
||||
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->where('t.id = ' . $id)
|
||||
);
|
||||
$ticket = $db->loadObject();
|
||||
|
||||
if (!$ticket) {
|
||||
$this->sendJson(404, ['error' => 'Ticket not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Replies
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('r.*, u.name AS user_name')
|
||||
->from($db->quoteName('#__mokosuiteclient_ticket_replies', 'r'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||
->where('r.ticket_id = ' . $id)
|
||||
->order('r.created ASC')
|
||||
);
|
||||
$ticket->replies = $db->loadObjectList() ?: [];
|
||||
|
||||
// Attachments
|
||||
$ticket->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id);
|
||||
|
||||
$this->sendJson(200, $ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tickets — create a new ticket.
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$subject = $input->getString('subject', '');
|
||||
$body = $input->getRaw('body', '');
|
||||
|
||||
if (empty($subject)) {
|
||||
$this->sendJson(400, ['error' => 'Subject is required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$statusId = $input->getInt('status_id', 0) ?: null;
|
||||
$priorityId = $input->getInt('priority_id', 0) ?: null;
|
||||
$status = $input->getString('status', 'open');
|
||||
$priority = $input->getString('priority', 'normal');
|
||||
|
||||
// Resolve status_id from alias if not provided
|
||||
if (!$statusId && $status) {
|
||||
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($status));
|
||||
$statusId = (int) $db->setQuery($q, 0, 1)->loadResult() ?: null;
|
||||
}
|
||||
if (!$priorityId && $priority) {
|
||||
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($priority));
|
||||
$priorityId = (int) $db->setQuery($q, 0, 1)->loadResult() ?: null;
|
||||
}
|
||||
|
||||
$ticket = (object) [
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $status,
|
||||
'status_id' => $statusId,
|
||||
'priority' => $priority,
|
||||
'priority_id' => $priorityId,
|
||||
'category_id' => $input->getInt('category_id', 0) ?: null,
|
||||
'created_by' => (int) Factory::getUser()->id,
|
||||
'assigned_to' => $input->getInt('assigned_to', 0) ?: null,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
|
||||
|
||||
// Trigger notification
|
||||
\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::notify('ticket_created', $ticket);
|
||||
|
||||
$this->sendJson(201, ['id' => (int) $ticket->id, 'message' => 'Ticket created']);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /tickets/{id} — update ticket fields.
|
||||
*/
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$id = $input->getInt('id', 0);
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Type-safe input extraction
|
||||
$fields = [];
|
||||
$intFields = ['status_id', 'priority_id', 'category_id', 'assigned_to'];
|
||||
$strFields = ['status', 'priority'];
|
||||
|
||||
foreach ($intFields as $field) {
|
||||
$value = $input->getInt($field, 0);
|
||||
if ($value > 0) { $fields[$field] = $value; }
|
||||
}
|
||||
foreach ($strFields as $field) {
|
||||
$value = $input->getString($field, '');
|
||||
if ($value !== '') { $fields[$field] = $value; }
|
||||
}
|
||||
|
||||
if (empty($fields)) {
|
||||
$this->sendJson(400, ['error' => 'No fields to update']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync status/status_id if only one is provided
|
||||
if (isset($fields['status']) && !isset($fields['status_id'])) {
|
||||
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($fields['status']));
|
||||
$resolved = (int) $db->setQuery($q, 0, 1)->loadResult();
|
||||
if ($resolved) { $fields['status_id'] = $resolved; }
|
||||
} elseif (isset($fields['status_id']) && !isset($fields['status'])) {
|
||||
$q = $db->getQuery(true)->select('alias')->from('#__mokosuiteclient_ticket_statuses')
|
||||
->where('id = ' . (int) $fields['status_id']);
|
||||
$alias = $db->setQuery($q, 0, 1)->loadResult();
|
||||
if ($alias) { $fields['status'] = $alias; }
|
||||
}
|
||||
if (isset($fields['priority']) && !isset($fields['priority_id'])) {
|
||||
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($fields['priority']));
|
||||
$resolved = (int) $db->setQuery($q, 0, 1)->loadResult();
|
||||
if ($resolved) { $fields['priority_id'] = $resolved; }
|
||||
} elseif (isset($fields['priority_id']) && !isset($fields['priority'])) {
|
||||
$q = $db->getQuery(true)->select('alias')->from('#__mokosuiteclient_ticket_priorities')
|
||||
->where('id = ' . (int) $fields['priority_id']);
|
||||
$alias = $db->setQuery($q, 0, 1)->loadResult();
|
||||
if ($alias) { $fields['priority'] = $alias; }
|
||||
}
|
||||
|
||||
$sets = [];
|
||||
foreach ($fields as $k => $v) {
|
||||
$sets[] = $db->quoteName($k) . ' = ' . (is_int($v) ? $v : $db->quote($v));
|
||||
}
|
||||
$sets[] = 'modified = ' . $db->quote(Factory::getDate()->toSql());
|
||||
|
||||
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets') . ' SET ' . implode(', ', $sets) . ' WHERE id = ' . $id)->execute();
|
||||
|
||||
if ($db->getAffectedRows() === 0) {
|
||||
$this->sendJson(404, ['error' => 'Ticket not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendJson(200, ['id' => $id, 'message' => 'Ticket updated', 'updated' => array_keys($fields)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tickets/{id}/reply — add a reply.
|
||||
*/
|
||||
public function reply(): void
|
||||
{
|
||||
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$ticketId = $input->getInt('id', 0);
|
||||
$body = $input->getRaw('body', '');
|
||||
|
||||
if (!$ticketId || empty($body)) {
|
||||
$this->sendJson(400, ['error' => 'ticket_id and body are required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$reply = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'user_id' => (int) Factory::getUser()->id,
|
||||
'body' => $body,
|
||||
'is_internal' => $input->getInt('is_internal', 0),
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
|
||||
|
||||
// Notify
|
||||
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId));
|
||||
$ticket = $db->loadObject();
|
||||
if ($ticket) {
|
||||
\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
|
||||
}
|
||||
|
||||
$this->sendJson(201, ['reply_id' => (int) $reply->id, 'message' => 'Reply added']);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
private function requireAuth(string $action, string $asset): void
|
||||
{
|
||||
$user = Factory::getUser();
|
||||
if (!$user->authorise($action, $asset)) {
|
||||
$this->sendJson(403, ['error' => 'Not authorized']);
|
||||
throw new \RuntimeException('Not authorized', 403);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendJson(int $code, $payload): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$app->setHeader('Content-Type', 'application/json', true);
|
||||
$app->setHeader('Status', (string) $code, true);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$app->close();
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,77 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
}
|
||||
|
||||
// Heartbeat + PIN send button
|
||||
var hbBtn = document.getElementById('mokosuiteclient-btn-heartbeat-pin');
|
||||
if (hbBtn) {
|
||||
hbBtn.addEventListener('click', function () {
|
||||
var btn = this;
|
||||
var url = btn.dataset.url;
|
||||
var token = btn.dataset.token;
|
||||
var icon = btn.querySelector('span');
|
||||
|
||||
btn.disabled = true;
|
||||
if (icon) icon.className = 'icon-spinner icon-spin';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append(token, '1');
|
||||
|
||||
fetch(url, {method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d.success) {
|
||||
Joomla.renderMessages({message: [d.message || 'Heartbeat sent to HQ.']});
|
||||
} else {
|
||||
Joomla.renderMessages({error: [d.message || 'Heartbeat failed.']});
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
Joomla.renderMessages({error: ['Network error sending heartbeat.']});
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
if (icon) icon.className = 'icon-upload';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Request PIN button
|
||||
var pinBtn = document.getElementById('mokosuiteclient-request-pin');
|
||||
if (pinBtn) {
|
||||
pinBtn.addEventListener('click', function () {
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
var fd = new FormData();
|
||||
fd.append(btn.dataset.token, '1');
|
||||
fetch(btn.dataset.url, {method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d.success && d.pin) {
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge bg-dark';
|
||||
badge.style.cssText = 'font-family:monospace;letter-spacing:0.08em;cursor:help;';
|
||||
badge.title = 'Support PIN — valid for 72 hours';
|
||||
badge.textContent = d.pin;
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'icon-key small me-1';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
badge.prepend(icon);
|
||||
btn.replaceWith(badge);
|
||||
} else {
|
||||
Joomla.renderMessages({error: [d.message || 'Failed to generate PIN']});
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Request PIN';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
Joomla.renderMessages({error: ['Network error']});
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Request PIN';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Akeeba import buttons
|
||||
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
|
||||
var btn = document.getElementById(id);
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>MokoSuiteClient admin dashboard and REST API. Provides a control panel for managing MokoSuiteClient feature plugins, site health monitoring, and remote management endpoints.</description>
|
||||
|
||||
<namespace path="src">Moko\Component\MokoSuiteClient</namespace>
|
||||
@@ -42,7 +42,6 @@
|
||||
<submenu>
|
||||
<menu link="option=com_mokosuiteclient" img="class:cogs">COM_MOKOSUITECLIENT_MENU_DASHBOARD</menu>
|
||||
<menu link="option=com_mokosuiteclient&view=extensions" img="class:puzzle-piece">COM_MOKOSUITECLIENT_MENU_EXTENSIONS</menu>
|
||||
<menu link="option=com_mokosuiteclient&view=tickets" img="class:headphones">COM_MOKOSUITECLIENT_MENU_TICKETS</menu>
|
||||
<menu link="option=com_mokosuiteclient&view=htaccess" img="class:file-code">COM_MOKOSUITECLIENT_MENU_HTACCESS</menu>
|
||||
<menu link="option=com_mokosuiteclient&view=privacy" img="class:lock">COM_MOKOSUITECLIENT_MENU_PRIVACY</menu>
|
||||
<menu link="option=com_mokosuiteclient&view=waflog" img="class:shield-alt">COM_MOKOSUITECLIENT_MENU_WAFLOG</menu>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Site\View\Ticket;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $ticket;
|
||||
protected $isStaff = false;
|
||||
protected $canAssign = false;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
|
||||
$this->isStaff = $user->authorise('core.admin') || $user->authorise('mokosuiteclient.tickets', 'com_mokosuiteclient');
|
||||
$this->canAssign = $user->authorise('core.admin') || $user->authorise('mokosuiteclient.tickets.assign', 'com_mokosuiteclient');
|
||||
|
||||
// Get ticket — staff see any, customers see only their own
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t') . '.*',
|
||||
$db->quoteName('c.title', 'category_title'),
|
||||
$db->quoteName('u.name', 'created_by_name'),
|
||||
$db->quoteName('u.email', 'created_by_email'),
|
||||
$db->quoteName('a.name', 'assigned_to_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
|
||||
->where($db->quoteName('t.id') . ' = ' . $id);
|
||||
|
||||
if (!$this->isStaff)
|
||||
{
|
||||
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$this->ticket = $db->loadObject();
|
||||
|
||||
if (!$this->ticket)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
||||
Factory::getApplication()->redirect(Route::_('index.php?option=com_mokosuiteclient&view=tickets', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load replies — staff see internal notes, customers don't
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('r') . '.*',
|
||||
$db->quoteName('u.name', 'user_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokosuiteclient_ticket_replies', 'r'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||
->where($db->quoteName('r.ticket_id') . ' = ' . $id);
|
||||
|
||||
if (!$this->isStaff)
|
||||
{
|
||||
$query->where($db->quoteName('r.is_internal') . ' = 0');
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('r.created') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$this->ticket->replies = $db->loadObjectList() ?: [];
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Site\View\Tickets;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $tickets = [];
|
||||
protected $categories = [];
|
||||
protected $isStaff = false;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
$this->isStaff = $user->authorise('core.admin')
|
||||
|| $user->authorise('mokosuiteclient.tickets', 'com_mokosuiteclient');
|
||||
|
||||
// Staff see all tickets, customers see their own
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t.id'),
|
||||
$db->quoteName('t.subject'),
|
||||
$db->quoteName('t.status'),
|
||||
$db->quoteName('t.priority'),
|
||||
$db->quoteName('t.created'),
|
||||
$db->quoteName('t.assigned_to'),
|
||||
$db->quoteName('c.title', 'category_title'),
|
||||
$db->quoteName('u.name', 'created_by_name'),
|
||||
$db->quoteName('a.name', 'assigned_to_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
|
||||
|
||||
if (!$this->isStaff)
|
||||
{
|
||||
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
|
||||
}
|
||||
|
||||
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
|
||||
|
||||
if ($filterStatus)
|
||||
{
|
||||
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filterStatus));
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('t.created') . ' DESC')->setLimit(50);
|
||||
$db->setQuery($query);
|
||||
$this->tickets = $db->loadObjectList() ?: [];
|
||||
|
||||
// Categories for new ticket form
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('title')])
|
||||
->from($db->quoteName('#__mokosuiteclient_ticket_categories'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$this->categories = $db->loadObjectList() ?: [];
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
$t = $this->ticket;
|
||||
$isStaff = $this->isStaff;
|
||||
$canAssign = $this->canAssign;
|
||||
$token = Session::getFormToken();
|
||||
$userId = Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
$statusLabel = [
|
||||
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
|
||||
'resolved' => 'Resolved', 'closed' => 'Closed',
|
||||
];
|
||||
$statusClass = [
|
||||
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
|
||||
'resolved' => 'success', 'closed' => 'secondary',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="mokosuiteclient-portal-ticket">
|
||||
<div class="mb-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=tickets'); ?>" class="btn btn-sm btn-outline-secondary">
|
||||
<span class="icon-arrow-left"></span> Back to Tickets
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Main column: conversation -->
|
||||
<div class="col-12 <?php echo $isStaff ? 'col-lg-8' : ''; ?>">
|
||||
|
||||
<!-- Ticket header -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h3 class="mb-1">#<?php echo $t->id; ?> — <?php echo htmlspecialchars($t->subject); ?></h3>
|
||||
<small class="text-muted">
|
||||
<?php echo htmlspecialchars($t->category_title ?? 'General'); ?>
|
||||
· <?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?>
|
||||
· <?php echo ucfirst($t->priority); ?>
|
||||
<?php if ($isStaff): ?>
|
||||
· By: <?php echo htmlspecialchars($t->created_by_name); ?>
|
||||
<?php endif; ?>
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?> fs-6">
|
||||
<?php echo $statusLabel[$t->status] ?? $t->status; ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Original message -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<strong><?php echo htmlspecialchars($t->created_by_name); ?></strong>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br(htmlspecialchars($t->body)); ?></div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
<?php foreach ($t->replies as $reply): ?>
|
||||
<?php
|
||||
$replyIsStaffUser = ((int) $reply->user_id !== (int) $t->created_by);
|
||||
$isInternal = (int) $reply->is_internal;
|
||||
?>
|
||||
<div class="card mb-3 <?php echo $isInternal ? 'border-warning bg-warning bg-opacity-10' : ($replyIsStaffUser ? 'border-primary' : ''); ?>">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<div>
|
||||
<strong><?php echo htmlspecialchars($reply->user_name ?? 'Support'); ?></strong>
|
||||
<?php if ($replyIsStaffUser): ?><span class="badge bg-primary ms-1">Staff</span><?php endif; ?>
|
||||
<?php if ($isInternal): ?><span class="badge bg-warning text-dark ms-1">Internal Note</span><?php endif; ?>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br(htmlspecialchars($reply->body)); ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Reply form -->
|
||||
<?php if (!\in_array($t->status, ['closed'])): ?>
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5>Reply</h5>
|
||||
<form id="portalReply">
|
||||
<textarea name="body" class="form-control mb-3" rows="5" required placeholder="Type your reply..."></textarea>
|
||||
<input type="hidden" name="ticket_id" value="<?php echo $t->id; ?>">
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-paper-plane"></span> Send Reply
|
||||
</button>
|
||||
<?php if ($isStaff): ?>
|
||||
<button type="button" class="btn btn-outline-warning" id="btn-internal-note">
|
||||
<span class="icon-eye-slash"></span> Internal Note
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($t->status === 'closed'): ?>
|
||||
<div class="alert alert-secondary mt-4">
|
||||
This ticket is closed. <a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=tickets&layout=submit'); ?>">Open a new ticket</a> if you need further help.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Staff sidebar -->
|
||||
<?php if ($isStaff): ?>
|
||||
<div class="col-12 col-lg-4">
|
||||
<!-- Ticket info -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Details</strong></div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5 text-muted">Status</dt>
|
||||
<dd class="col-7"><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></dd>
|
||||
<dt class="col-5 text-muted">Priority</dt>
|
||||
<dd class="col-7"><?php echo ucfirst($t->priority); ?></dd>
|
||||
<dt class="col-5 text-muted">Category</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->category_title ?? '—'); ?></dd>
|
||||
<dt class="col-5 text-muted">Submitted By</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->created_by_name); ?><br><small class="text-muted"><?php echo htmlspecialchars($t->created_by_email ?? ''); ?></small></dd>
|
||||
<dt class="col-5 text-muted">Assigned To</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->assigned_to_name ?? 'Unassigned'); ?></dd>
|
||||
<dt class="col-5 text-muted">Created</dt>
|
||||
<dd class="col-7"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></dd>
|
||||
<dt class="col-5 text-muted">Replies</dt>
|
||||
<dd class="col-7"><?php echo \count($t->replies); ?></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status actions -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Change Status</strong></div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
|
||||
<?php if ($s !== $t->status): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
|
||||
data-status="<?php echo $s; ?>">
|
||||
<?php echo $label; ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($canAssign): ?>
|
||||
<!-- Quick assign -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Assign</strong></div>
|
||||
<div class="card-body">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary w-100" id="btn-assign-me">
|
||||
Assign to Me
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
var ticketId = <?php echo $t->id; ?>;
|
||||
|
||||
// Reply
|
||||
var replyForm = document.getElementById('portalReply');
|
||||
if (replyForm) {
|
||||
replyForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
sendReply(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Internal note
|
||||
var internalBtn = document.getElementById('btn-internal-note');
|
||||
if (internalBtn) {
|
||||
internalBtn.addEventListener('click', function() { sendReply(true); });
|
||||
}
|
||||
|
||||
function sendReply(isInternal) {
|
||||
var body = replyForm.querySelector('textarea[name=body]').value.trim();
|
||||
if (!body) return;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('body', body);
|
||||
fd.append('is_internal', isInternal ? '1' : '0');
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokosuiteclient&task=display.submitReply&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Status buttons
|
||||
document.querySelectorAll('.btn-status').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('status', this.dataset.status);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokosuiteclient&task=display.updateStatus&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Assign to me
|
||||
var assignBtn = document.getElementById('btn-assign-me');
|
||||
if (assignBtn) {
|
||||
assignBtn.addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('assigned_to', <?php echo $userId; ?>);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokosuiteclient&task=display.assignTicket&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$tickets = $this->tickets;
|
||||
$categories = $this->categories;
|
||||
$isStaff = $this->isStaff;
|
||||
$token = Session::getFormToken();
|
||||
|
||||
$statusLabel = [
|
||||
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
|
||||
'resolved' => 'Resolved', 'closed' => 'Closed',
|
||||
];
|
||||
$statusClass = [
|
||||
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
|
||||
'resolved' => 'success', 'closed' => 'secondary',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="mokosuiteclient-portal">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><?php echo $isStaff ? 'All Support Tickets' : 'My Support Tickets'; ?></h2>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=tickets&layout=submit'); ?>" class="btn btn-primary">
|
||||
<span class="icon-plus"></span> New Ticket
|
||||
</a>
|
||||
<?php if ($isStaff): ?>
|
||||
<form method="get" class="d-inline">
|
||||
<input type="hidden" name="option" value="com_mokosuiteclient">
|
||||
<input type="hidden" name="view" value="tickets">
|
||||
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<?php foreach ($statusLabel as $k => $v): ?>
|
||||
<option value="<?php echo $k; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $k ? 'selected' : ''; ?>><?php echo $v; ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($tickets)): ?>
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle"></span>
|
||||
<?php echo $isStaff ? 'No tickets found.' : 'You haven\'t submitted any support tickets yet.'; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Subject</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Category</th>
|
||||
<?php if ($isStaff): ?><th>Submitted By</th><th>Assigned To</th><?php endif; ?>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tickets as $t): ?>
|
||||
<tr>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=' . $t->id); ?>"><?php echo htmlspecialchars(mb_substr($t->subject, 0, 60)); ?></a></td>
|
||||
<td><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></td>
|
||||
<td><?php echo ucfirst($t->priority); ?></td>
|
||||
<td><?php echo htmlspecialchars($t->category_title ?? '—'); ?></td>
|
||||
<?php if ($isStaff): ?>
|
||||
<td><?php echo htmlspecialchars($t->created_by_name ?? ''); ?></td>
|
||||
<td><?php echo htmlspecialchars($t->assigned_to_name ?? '<em>Unassigned</em>'); ?></td>
|
||||
<?php endif; ?>
|
||||
<td class="text-nowrap"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -1,204 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Submit a Ticket layout — search KB first, then submit form.
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$categories = $this->categories;
|
||||
$token = Session::getFormToken();
|
||||
$searchUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.searchKb&format=json');
|
||||
$submitUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.submitTicket&format=json');
|
||||
$ticketUrl = Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=');
|
||||
$ticketsUrl = Route::_('index.php?option=com_mokosuiteclient&view=tickets');
|
||||
|
||||
// Check if Smart Search has indexed content
|
||||
$finderEnabled = false;
|
||||
try {
|
||||
$db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1');
|
||||
$finderEnabled = (int) $db->loadResult() > 0;
|
||||
} catch (\Throwable $e) {}
|
||||
?>
|
||||
|
||||
<div class="mokosuiteclient-portal">
|
||||
<h2>Submit a Support Request</h2>
|
||||
|
||||
<?php if ($finderEnabled): ?>
|
||||
<!-- Step 1: Search -->
|
||||
<div id="step-search" class="mb-4">
|
||||
<p class="text-muted">Before submitting, let's see if we already have an answer for you.</p>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<label class="form-label fw-bold" for="kb-search">Describe your issue</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<input type="text" id="kb-search" class="form-control" placeholder="e.g. how do I reset my password?" autofocus>
|
||||
<button type="button" class="btn btn-primary" id="kb-search-btn">
|
||||
<span class="icon-search"></span> Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search results -->
|
||||
<div id="kb-results" class="mt-3 d-none">
|
||||
<h5>Related Articles</h5>
|
||||
<div id="kb-results-list" class="list-group mb-3"></div>
|
||||
<p class="text-muted">Didn't find what you need?</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-outline-primary" id="btn-show-form">
|
||||
<span class="icon-plus"></span> Submit a Ticket Anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Step 2: Ticket Form -->
|
||||
<div id="step-form" class="<?php echo $finderEnabled ? 'd-none' : ''; ?>">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">Ticket Details</h5>
|
||||
<form id="submitTicketForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ticket-subject">Subject <span class="text-danger">*</span></label>
|
||||
<input type="text" id="ticket-subject" name="subject" class="form-control" required placeholder="Brief description of your issue">
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="ticket-category">Category</label>
|
||||
<select id="ticket-category" name="category_id" class="form-select">
|
||||
<option value="">Select a category</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="ticket-priority">Priority</label>
|
||||
<select id="ticket-priority" name="priority" class="form-select">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ticket-body">Description <span class="text-danger">*</span></label>
|
||||
<textarea id="ticket-body" name="body" class="form-control" rows="8" required placeholder="Please describe your issue in detail."></textarea>
|
||||
</div>
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<span class="icon-paper-plane"></span> Submit Ticket
|
||||
</button>
|
||||
<a href="<?php echo $ticketsUrl; ?>" class="btn btn-outline-secondary btn-lg">
|
||||
My Tickets
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var searchInput = document.getElementById('kb-search');
|
||||
var searchBtn = document.getElementById('kb-search-btn');
|
||||
var resultBox = document.getElementById('kb-results');
|
||||
var resultList = document.getElementById('kb-results-list');
|
||||
var showFormBtn = document.getElementById('btn-show-form');
|
||||
var stepSearch = document.getElementById('step-search');
|
||||
var stepForm = document.getElementById('step-form');
|
||||
var subjectField = document.getElementById('ticket-subject');
|
||||
|
||||
// Search
|
||||
function doSearch() {
|
||||
var q = (searchInput ? searchInput.value.trim() : '');
|
||||
if (q.length < 3) return;
|
||||
|
||||
fetch('<?php echo $searchUrl; ?>&q=' + encodeURIComponent(q), {
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
resultList.textContent = '';
|
||||
if (d.results && d.results.length > 0) {
|
||||
d.results.forEach(function(item) {
|
||||
var a = document.createElement('a');
|
||||
a.href = item.url;
|
||||
a.target = '_blank';
|
||||
a.className = 'list-group-item list-group-item-action';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = item.title;
|
||||
a.appendChild(strong);
|
||||
if (item.description) {
|
||||
a.appendChild(document.createElement('br'));
|
||||
var small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = item.description;
|
||||
a.appendChild(small);
|
||||
}
|
||||
resultList.appendChild(a);
|
||||
});
|
||||
resultBox.classList.remove('d-none');
|
||||
} else {
|
||||
resultBox.classList.add('d-none');
|
||||
}
|
||||
// Always show the "submit anyway" button after search
|
||||
if (showFormBtn) showFormBtn.classList.remove('d-none');
|
||||
});
|
||||
}
|
||||
|
||||
if (searchBtn) searchBtn.addEventListener('click', doSearch);
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Show form and prefill subject from search query
|
||||
if (showFormBtn) {
|
||||
showFormBtn.addEventListener('click', function() {
|
||||
if (stepSearch) stepSearch.classList.add('d-none');
|
||||
if (stepForm) stepForm.classList.remove('d-none');
|
||||
if (searchInput && subjectField && !subjectField.value) {
|
||||
subjectField.value = searchInput.value;
|
||||
}
|
||||
subjectField.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Submit ticket
|
||||
var form = document.getElementById('submitTicketForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = form.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = ' Submitting...';
|
||||
var fd = new FormData(form);
|
||||
fetch('<?php echo $submitUrl; ?>', {
|
||||
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success && d.id) {
|
||||
window.location.href = '<?php echo $ticketUrl; ?>' + d.id;
|
||||
} else {
|
||||
alert(d.message || 'Failed.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = ' Submit Ticket';
|
||||
}
|
||||
})
|
||||
.catch(function() { alert('Network error.'); btn.disabled = false; });
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>MOD_MOKOSUITECLIENT_CACHE_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientCache</namespace>
|
||||
|
||||
|
||||
@@ -11,34 +11,25 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$token = Session::getFormToken();
|
||||
$cacheUrl = 'index.php?option=com_mokosuiteclient&task=clearCache&format=json';
|
||||
$tempUrl = 'index.php?option=com_mokosuiteclient&task=clearTemp&format=json';
|
||||
$cacheUrl = 'index.php?option=com_mokosuiteclient&task=display.clearCache&format=json';
|
||||
$tempUrl = 'index.php?option=com_mokosuiteclient&task=display.clearTemp&format=json';
|
||||
$domain = $domain ?? '';
|
||||
?>
|
||||
|
||||
<style>
|
||||
.mokosuiteclient-cleaner { display:flex; align-items:center; gap:0; padding:0 0.25rem; }
|
||||
.mokosuiteclient-cleaner-label { font-size:0.8rem; color:var(--template-text-dark,#495057); white-space:nowrap; padding-inline-end:0.35rem; }
|
||||
.mokosuiteclient-cleaner-btn { cursor:pointer; padding:0.2rem 0.5rem; font-size:0.8rem; border-radius:3px; text-decoration:none; color:var(--template-text-dark,#495057); transition:background 0.15s; white-space:nowrap; }
|
||||
.mokosuiteclient-cleaner-btn:hover { background:rgba(0,0,0,0.08); color:var(--template-text-dark,#212529); text-decoration:none; }
|
||||
.mokosuiteclient-cleaner-sep { color:var(--template-text-dark,#adb5bd); padding:0 0.1rem; font-size:0.8rem; }
|
||||
.mokosuiteclient-domain { font-family:monospace; font-size:0.75rem; color:var(--template-text-dark,#6c757d); cursor:pointer; padding:0.15rem 0.4rem; border-radius:3px; transition:background 0.15s; }
|
||||
.mokosuiteclient-domain:hover { background:rgba(0,0,0,0.06); }
|
||||
</style>
|
||||
|
||||
<div class="header-item-content mokosuiteclient-cleaner">
|
||||
<?php if ($domain): ?>
|
||||
<span class="mokosuiteclient-domain" id="mokosuiteclient-domain" title="Support key — click to copy"><?php echo htmlspecialchars($domain); ?></span>
|
||||
<span class="mokosuiteclient-cleaner-sep">|</span>
|
||||
<?php endif; ?>
|
||||
<span class="mokosuiteclient-cleaner-label">Clear:</span>
|
||||
<a href="#" class="mokosuiteclient-cleaner-btn" id="mokosuiteclient-clear-cache" title="Clear all Joomla cache">
|
||||
<span class="icon-bolt" aria-hidden="true" id="mokosuiteclient-cache-icon"></span> Cache
|
||||
</a>
|
||||
<span class="mokosuiteclient-cleaner-sep">|</span>
|
||||
<a href="#" class="mokosuiteclient-cleaner-btn" id="mokosuiteclient-clear-temp" title="Clear temp directory">
|
||||
<span class="icon-trash" aria-hidden="true" id="mokosuiteclient-temp-icon"></span> Temp
|
||||
</a>
|
||||
<div class="header-item">
|
||||
<div class="header-item-content d-flex align-items-center gap-0" style="padding:0;">
|
||||
<?php if ($domain): ?>
|
||||
<a href="#" class="btn btn-sm btn-outline-secondary rounded-0 rounded-start border-end-0 d-flex align-items-center gap-1 px-3 py-2" id="mokosuiteclient-domain" title="Support key — click to copy" style="font-size:0.8rem;">
|
||||
<span class="icon-key" aria-hidden="true"></span> <?php echo htmlspecialchars($domain); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<a href="#" class="btn btn-sm btn-outline-primary <?php echo $domain ? 'rounded-0 border-end-0' : 'rounded-0 rounded-start border-end-0'; ?> d-flex align-items-center gap-1 px-3 py-2" id="mokosuiteclient-clear-cache" title="Clear all Joomla cache" style="font-size:0.8rem;">
|
||||
<span class="icon-bolt" aria-hidden="true" id="mokosuiteclient-cache-icon"></span> Cache
|
||||
</a>
|
||||
<a href="#" class="btn btn-sm btn-outline-danger rounded-0 rounded-end d-flex align-items-center gap-1 px-3 py-2" id="mokosuiteclient-clear-temp" title="Clear temp directory" style="font-size:0.8rem;">
|
||||
<span class="icon-trash" aria-hidden="true" id="mokosuiteclient-temp-icon"></span> Temp
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>MOD_MOKOSUITECLIENT_CATEGORIES_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientCategories</namespace>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>MOD_MOKOSUITECLIENT_CPANEL_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientCpanel</namespace>
|
||||
|
||||
|
||||
@@ -47,8 +47,9 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
|
||||
$data['currentIp'] = $helper->getCurrentIp();
|
||||
$data['ssl'] = $helper->getSslStatus();
|
||||
|
||||
// Daily support PIN derived from health token + today's date (UTC)
|
||||
// Support PIN — only shown if requested within last 72 hours
|
||||
$data['supportPin'] = '';
|
||||
$data['supportPinAvailable'] = false;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -65,9 +66,17 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
|
||||
|
||||
if (!empty($token))
|
||||
{
|
||||
$date = gmdate('Y-m-d');
|
||||
$hash = hash_hmac('sha256', $date, $token);
|
||||
$data['supportPin'] = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
|
||||
$data['supportPinAvailable'] = true;
|
||||
$pinRequestedAt = $coreParams->support_pin_requested_at ?? '';
|
||||
$pinTtl = 72 * 3600; // 72 hours
|
||||
|
||||
if (!empty($pinRequestedAt) && (time() - (int) $pinRequestedAt) < $pinTtl)
|
||||
{
|
||||
// PIN is active — generate from the request timestamp (stable for 72h window)
|
||||
$window = floor((int) $pinRequestedAt / $pinTtl);
|
||||
$hash = hash_hmac('sha256', (string) $window, $token);
|
||||
$data['supportPin'] = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
|
||||
@@ -32,9 +32,11 @@ class CpanelHelper
|
||||
$pkgCache = json_decode($db->loadResult() ?? '{}');
|
||||
|
||||
return (object) [
|
||||
'sitename' => $config->get('sitename', ''),
|
||||
'mokosuiteclient_version' => $pkgCache->version ?? '',
|
||||
'joomla_version' => (new Version())->getShortVersion(),
|
||||
'php_version' => PHP_VERSION,
|
||||
'db_type' => $config->get('dbtype', 'mysql'),
|
||||
'debug' => (bool) $config->get('debug'),
|
||||
'offline' => (bool) $config->get('offline'),
|
||||
];
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
@@ -60,175 +61,70 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
||||
?>
|
||||
|
||||
<div class="mod-mokosuiteclient-cpanel card p-3 mb-4">
|
||||
<!-- Header row -->
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-sm btn-link p-0 text-muted" data-bs-toggle="collapse" data-bs-target="#mokosuiteclient-cpanel-body" aria-expanded="<?php echo $collapsed ? 'false' : 'true'; ?>" aria-controls="mokosuiteclient-cpanel-body" id="mokosuiteclient-cpanel-toggle" style="font-size:1rem;line-height:1;width:1.5rem;">
|
||||
<span class="fa-solid fa-caret-<?php echo $collapsed ? 'right' : 'down'; ?>" aria-hidden="true" id="mokosuiteclient-cpanel-caret"></span>
|
||||
</button>
|
||||
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.25rem;color:#1a2744"></span>
|
||||
<strong>MokoSuite</strong>
|
||||
<span class="badge bg-primary"><?php echo htmlspecialchars($siteInfo->mokosuiteclient_version ?? ''); ?></span>
|
||||
<?php if (!empty($supportPin)): ?>
|
||||
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;" title="Support PIN"><?php echo htmlspecialchars($supportPin); ?></span>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2" style="font-size:0.85rem;">
|
||||
<?php $canDashboard = Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokosuiteclient'); ?>
|
||||
<?php if ($canDashboard): ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient'); ?>" style="color:#1a2744;text-decoration:none;" title="MokoSuite Dashboard"><span class="icon-shield-alt" aria-hidden="true" style="font-size:1.1rem"></span></a>
|
||||
<?php else: ?>
|
||||
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.1rem;color:#1a2744"></span>
|
||||
<?php endif; ?>
|
||||
<span class="fw-bold"><?php echo htmlspecialchars($siteInfo->sitename ?? ''); ?></span>
|
||||
<span class="badge bg-primary">MokoSuite <?php echo htmlspecialchars($siteInfo->mokosuiteclient_version ?? ''); ?></span>
|
||||
<?php if (!empty($supportPin)): ?>
|
||||
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="Support PIN — valid for 72 hours"><span class="icon-key small me-1" aria-hidden="true"></span><?php echo htmlspecialchars($supportPin); ?></span>
|
||||
<?php elseif (!empty($supportPinAvailable)): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark py-0 px-2" id="mokosuiteclient-request-pin"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.requestPin&format=json'); ?>"
|
||||
data-token="<?php echo Session::getFormToken(); ?>"
|
||||
style="font-size:0.75rem;" title="Request a support PIN (valid 72 hours)">
|
||||
<span class="icon-key" aria-hidden="true"></span> Request PIN
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<span class="badge bg-secondary">Joomla <?php echo htmlspecialchars($siteInfo->joomla_version ?? ''); ?></span>
|
||||
<span class="badge bg-secondary">PHP <?php echo htmlspecialchars($siteInfo->php_version ?? ''); ?></span>
|
||||
<span class="badge bg-secondary"><?php echo htmlspecialchars($siteInfo->db_type ?? ''); ?></span>
|
||||
<?php if (!empty($siteInfo->debug)): ?>
|
||||
<span class="badge bg-warning text-dark">Debug</span>
|
||||
<span class="badge bg-warning text-dark">Debug ON</span>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($siteInfo->offline)): ?>
|
||||
<span class="badge bg-danger">Offline</span>
|
||||
<?php endif; ?>
|
||||
<?php if (($counts->moko_updates ?? 0) > 0): ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-info text-decoration-none" title="MokoSuite updates available">
|
||||
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->moko_updates; ?> MokoSuite update<?php echo $counts->moko_updates > 1 ? 's' : ''; ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($counts->updates > 0 && $counts->updates !== ($counts->moko_updates ?? 0)): ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-warning text-dark text-decoration-none" title="Other updates available">
|
||||
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->updates - ($counts->moko_updates ?? 0); ?> update<?php echo ($counts->updates - ($counts->moko_updates ?? 0)) > 1 ? 's' : ''; ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<span class="ms-auto">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient'); ?>" class="btn btn-sm btn-primary">
|
||||
<span class="icon-cogs" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOSUITECLIENT_CPANEL_OPEN_DASHBOARD'); ?>
|
||||
</a>
|
||||
<span class="ms-auto d-flex align-items-center gap-2">
|
||||
<span class="icon-globe" aria-hidden="true"></span>
|
||||
<code><?php echo htmlspecialchars($currentIp); ?></code>
|
||||
</span>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var target = document.getElementById('mokosuiteclient-cpanel-body');
|
||||
var caret = document.getElementById('mokosuiteclient-cpanel-caret');
|
||||
if (target && caret) {
|
||||
target.addEventListener('show.bs.collapse', function() { caret.className = 'fa-solid fa-caret-down'; });
|
||||
target.addEventListener('hide.bs.collapse', function() { caret.className = 'fa-solid fa-caret-right'; });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Collapsible body -->
|
||||
<div class="collapse<?php echo $collapsed ? '' : ' show'; ?> mt-3" id="mokosuiteclient-cpanel-body">
|
||||
|
||||
<?php if ($showHealth && $showStats): ?>
|
||||
<!-- Health + stats row -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-2 text-center h-100">
|
||||
<?php if ($healthOk): ?>
|
||||
<span class="icon-check-circle text-success d-block" style="font-size:1.5rem"></span>
|
||||
<small class="text-success fw-bold">Healthy</small>
|
||||
<?php else: ?>
|
||||
<span class="icon-exclamation-circle text-danger d-block" style="font-size:1.5rem"></span>
|
||||
<small class="text-danger fw-bold">DB Error</small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-2 text-center h-100">
|
||||
<span class="fw-bold d-block" style="font-size:1.25rem"><?php echo $counts->articles; ?></span>
|
||||
<small class="text-muted">Articles</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-2 text-center h-100">
|
||||
<span class="fw-bold d-block" style="font-size:1.25rem"><?php echo $counts->users; ?></span>
|
||||
<small class="text-muted">Users</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-2 text-center h-100">
|
||||
<?php if ($counts->updates > 0): ?>
|
||||
<span class="fw-bold d-block text-warning" style="font-size:1.25rem"><?php echo $counts->updates; ?></span>
|
||||
<small class="text-warning">Updates</small>
|
||||
<?php else: ?>
|
||||
<span class="icon-check d-block text-success" style="font-size:1.25rem"></span>
|
||||
<small class="text-muted">Up to date</small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info + plugins + actions (consolidated) -->
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<?php if ($showDisk && $diskPct !== null): ?>
|
||||
<span class="text-muted d-inline-flex align-items-center gap-1">
|
||||
<span class="icon-hdd" aria-hidden="true"></span>
|
||||
<?php echo $diskPct; ?>%
|
||||
<span class="progress d-inline-flex" style="width:40px;height:5px"><span class="progress-bar <?php echo $diskColor; ?>" style="width:<?php echo $diskPct; ?>%"></span></span>
|
||||
<?php echo number_format(($disk->free_mb ?? 0) / 1024, 1); ?>G free
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($showIp && $currentIp): ?>
|
||||
<span class="text-muted"><span class="icon-globe" aria-hidden="true"></span> <code><?php echo htmlspecialchars($currentIp); ?></code></span>
|
||||
<?php endif; ?>
|
||||
<?php $ssl = $ssl ?? null; if ($ssl): ?>
|
||||
<span class="badge bg-<?php echo $ssl->critical ? 'danger' : ($ssl->warning ? 'warning text-dark' : 'success'); ?>" title="SSL expires <?php echo $ssl->expires; ?>">
|
||||
<span class="icon-lock" aria-hidden="true"></span>
|
||||
SSL <?php echo $ssl->days_remaining; ?>d
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($showVersions): ?>
|
||||
<span class="text-muted">J<?php echo htmlspecialchars($siteInfo->joomla_version ?? ''); ?> / PHP <?php echo htmlspecialchars($siteInfo->php_version ?? ''); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($showPlugins && !empty($plugins)): ?>
|
||||
<span class="border-start ps-2 ms-1"></span>
|
||||
<?php foreach ($plugins as $p): ?>
|
||||
<?php
|
||||
$label = $labels[$p->element] ?? $p->element;
|
||||
$badge = $p->enabled ? 'bg-success' : 'bg-secondary';
|
||||
$icon = $p->enabled ? 'icon-check' : 'icon-times';
|
||||
$configUrl = Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . (int) $p->extension_id);
|
||||
?>
|
||||
<a href="<?php echo $configUrl; ?>" class="badge <?php echo $badge; ?> text-decoration-none" title="<?php echo htmlspecialchars($p->name); ?>">
|
||||
<span class="<?php echo $icon; ?>" aria-hidden="true"></span> <?php echo htmlspecialchars($label); ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($showActions): ?>
|
||||
<span class="border-start ps-2 ms-1"></span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="mokosuiteclient-cpanel-cache"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.clearCache&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-trash" aria-hidden="true"></span> Clear Cache
|
||||
</button>
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-sm btn-outline-secondary">
|
||||
<span class="icon-refresh" aria-hidden="true"></span> Check Updates
|
||||
</a>
|
||||
<?php if ($counts->updates > 0): ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-warning text-dark text-decoration-none">
|
||||
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->updates; ?> update<?php echo $counts->updates > 1 ? 's' : ''; ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- /.collapse -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var btn = document.getElementById('mokosuiteclient-cpanel-cache');
|
||||
var btn = document.getElementById('mokosuiteclient-request-pin');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
var url = el.dataset.url;
|
||||
var token = el.dataset.token;
|
||||
el.disabled = true;
|
||||
var icon = el.querySelector('span');
|
||||
var origClass = icon ? icon.className : '';
|
||||
if (icon) icon.className = 'icon-spinner icon-spin';
|
||||
el.textContent = '...';
|
||||
var fd = new FormData();
|
||||
fd.append(token, '1');
|
||||
fetch(url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) Joomla.renderMessages({message:['Cache cleared.']});
|
||||
else Joomla.renderMessages({error:[d.message||'Failed']});
|
||||
if (d.success && d.pin) {
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge bg-dark';
|
||||
badge.style = 'font-family:monospace;letter-spacing:0.08em;cursor:help;';
|
||||
badge.title = 'Support PIN — valid for 72 hours';
|
||||
badge.innerHTML = '<span class="icon-key small me-1" aria-hidden="true"></span>' + d.pin;
|
||||
el.replaceWith(badge);
|
||||
} else {
|
||||
Joomla.renderMessages({error:[d.message||'Failed to generate PIN']});
|
||||
el.disabled = false;
|
||||
el.innerHTML = '<span class="icon-key" aria-hidden="true"></span> Request PIN';
|
||||
}
|
||||
})
|
||||
.catch(function(){Joomla.renderMessages({error:['Network error']})})
|
||||
.finally(function(){
|
||||
.catch(function(){
|
||||
Joomla.renderMessages({error:['Network error']});
|
||||
el.disabled = false;
|
||||
if (icon) icon.className = origClass;
|
||||
el.innerHTML = '<span class="icon-key" aria-hidden="true"></span> Request PIN';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu.</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientMenu</namespace>
|
||||
|
||||
|
||||
@@ -17,17 +17,26 @@ $app = Factory::getApplication();
|
||||
$currentOption = $app->getInput()->get('option', '');
|
||||
$currentView = $app->getInput()->get('view', '');
|
||||
|
||||
// ── Static views for com_mokosuiteclient ──────────────────────────────────
|
||||
$mokosuiteclientStaticViews = [
|
||||
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'],
|
||||
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions'],
|
||||
['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokosuiteclient&view=htaccess'],
|
||||
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy'],
|
||||
['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokosuiteclient&view=waflog'],
|
||||
['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokosuiteclient&view=database'],
|
||||
['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokosuiteclient&view=cleanup'],
|
||||
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient'],
|
||||
// ── Static views for com_mokosuiteclient (ACL-gated) ──────────────────────
|
||||
$user = $app->getIdentity();
|
||||
$allViews = [
|
||||
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient', 'acl' => 'mokosuiteclient.dashboard'],
|
||||
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions', 'acl' => 'mokosuiteclient.extensions'],
|
||||
['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokosuiteclient&view=htaccess', 'acl' => 'mokosuiteclient.htaccess'],
|
||||
['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokosuiteclient&view=waflog', 'acl' => 'mokosuiteclient.security.waflog'],
|
||||
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy', 'acl' => 'core.admin'],
|
||||
['icon' => 'fa-solid fa-code', 'title' => 'Snippets', 'link' => 'index.php?option=com_mokosuiteclient&view=snippets', 'acl' => 'mokosuiteclient.snippets.manage'],
|
||||
['icon' => 'fa-solid fa-file-lines', 'title' => 'Templates', 'link' => 'index.php?option=com_mokosuiteclient&view=templates', 'acl' => 'mokosuiteclient.templates.manage'],
|
||||
['icon' => 'fa-solid fa-right-left', 'title' => 'Replacements', 'link' => 'index.php?option=com_mokosuiteclient&view=replacements','acl' => 'mokosuiteclient.replacements.manage'],
|
||||
['icon' => 'fa-solid fa-shuffle', 'title' => 'Conditions', 'link' => 'index.php?option=com_mokosuiteclient&view=conditions', 'acl' => 'mokosuiteclient.conditions.manage'],
|
||||
['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokosuiteclient&view=database', 'acl' => 'core.admin'],
|
||||
['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokosuiteclient&view=cleanup', 'acl' => 'mokosuiteclient.cache'],
|
||||
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient', 'acl' => 'core.admin'],
|
||||
];
|
||||
$isSuper = $user->authorise('core.admin', 'com_mokosuiteclient');
|
||||
$mokosuiteclientStaticViews = array_filter($allViews, function ($v) use ($user, $isSuper) {
|
||||
return $isSuper || $user->authorise($v['acl'], 'com_mokosuiteclient');
|
||||
});
|
||||
|
||||
// ── Auto-discover all Moko components from #__menu ──────────────────
|
||||
$mokoComponents = [];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage plg_system_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* VERSION: 02.47.81
|
||||
* PATH: /src/Field/ArticlesField.php
|
||||
* BRIEF: List field that populates with published Joomla articles
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoSuiteClient\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\Field\ListField;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Form field that renders a dropdown (or multi-select) of all published
|
||||
* articles, grouped by category name.
|
||||
*
|
||||
* Usage in XML:
|
||||
* <field name="related_article" type="Articles" label="Related Article" multiple="true" />
|
||||
*
|
||||
* @since 02.47.62
|
||||
*/
|
||||
class ArticlesField extends ListField
|
||||
{
|
||||
protected $type = 'Articles';
|
||||
|
||||
protected function getOptions(): array
|
||||
{
|
||||
$options = parent::getOptions();
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('a.id', 'value'),
|
||||
$db->quoteName('a.title', 'text'),
|
||||
$db->quoteName('c.title', 'category'),
|
||||
])
|
||||
->from($db->quoteName('#__content', 'a'))
|
||||
->leftJoin($db->quoteName('#__categories', 'c') . ' ON c.id = a.catid')
|
||||
->where($db->quoteName('a.state') . ' = 1')
|
||||
->order($db->quoteName('a.title') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$articles = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($articles as $article) {
|
||||
$label = $article->text;
|
||||
if (!empty($article->category)) {
|
||||
$label .= ' [' . $article->category . ']';
|
||||
}
|
||||
$options[] = HTMLHelper::_('select.option', $article->value, $label);
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* VERSION: 02.47.27
|
||||
* VERSION: 02.47.81
|
||||
* PATH: /src/Field/CopyableTokenField.php
|
||||
* BRIEF: Read-only token field with a copy-to-clipboard button
|
||||
*/
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
|
||||
<namespace path=".">Moko\Plugin\System\MokoSuiteClient</namespace>
|
||||
<scriptfile>script.php</scriptfile>
|
||||
@@ -99,7 +99,94 @@
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC"
|
||||
filter="url" />
|
||||
|
||||
<field name="monitor_signing_key" type="hidden"
|
||||
<field name="auto_clear_cache" type="radio" default="0"
|
||||
label="Auto-Clear Cache on Save"
|
||||
description="Automatically clear Joomla cache when articles, modules, or extensions are saved."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="protect_emails" type="radio" default="0"
|
||||
label="Email Protection"
|
||||
description="Obfuscate email addresses in HTML output to prevent spam bot harvesting. Uses JavaScript decloaking."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="snippets_enabled" type="radio" default="0"
|
||||
label="Snippets"
|
||||
description="Enable {snippet alias="name"} content tags. Reusable text/HTML blocks stored in the database with variable substitution support."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="content_templates_enabled" type="radio" default="0"
|
||||
label="Content Templates"
|
||||
description="Enable {template alias="name"} content tags. Loads structured template data from the content_templates table and renders introtext + fulltext."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="articles_anywhere_enabled" type="radio" default="0"
|
||||
label="Articles Anywhere"
|
||||
description="Enable {article id="42"}[title]{/article} content tags. Insert article data anywhere using template placeholders for title, introtext, author, category, dates, images, and more."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="users_anywhere_enabled" type="radio" default="0"
|
||||
label="Users Anywhere"
|
||||
description="Allow {user} tags to display user information in content. Use {user id="42"}[name]{/user} for specific users or {user name} for the current logged-in user."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="users_allow_email" type="radio" default="0"
|
||||
label="Users: Show Email"
|
||||
description="Allow the [email] placeholder in {user} tags to display the real email address. When disabled, emails are masked."
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="users_anywhere_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="users_allow_username" type="radio" default="1"
|
||||
label="Users: Show Username"
|
||||
description="Allow the [username] placeholder in {user} tags to display the real username. When disabled, usernames are masked."
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="users_anywhere_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="replacements_enabled" type="radio" default="0"
|
||||
label="ReReplacer"
|
||||
description="Enable backend-managed string and regex replacement rules. Published rules from the replacements table are applied to site and/or admin content."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="sourcerer_enabled" type="radio" default="0"
|
||||
label="Code Embedding (Sourcerer)"
|
||||
description="Allow embedding PHP, JavaScript, and CSS code in content via {source} tags. Security restricted."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="sourcerer_forbidden_functions" type="text" default="exec,system,passthru,shell_exec,popen,proc_open,dl,eval,call_user_func,call_user_func_array,assert,array_map,array_filter,array_walk,usort,uasort,uksort,create_function,preg_replace_callback,ob_start"
|
||||
label="Forbidden PHP Functions"
|
||||
description="Comma-separated list of PHP functions blocked in {source} tags. Backtick operator is always blocked."
|
||||
showon="sourcerer_enabled:1" />
|
||||
|
||||
<field name="monitor_signing_key" type="hidden"
|
||||
default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQ2xZNnNzOTZpeTZOOGMKTHRxbndhbnU4eEozdDcrdDhXT3hoY0Yyclc2QmlmOVhNaEpnYkw0c055N0wwV1dTT2tkMmZxalBNcDFtOFNyNAo1VnNycjE3cFc5b0FNMmtmdFdsaTZ1NkhTVEYyN2pVVUJrT3o4MHZMRklMMGNGNkJCUkpYN2JVWkRpamdUMjc1ClREb3dXZy82Zk9GeWFEelBHUkJuYXFacTljU2lEYWoyNlpSTVZIbktQUERWTG92VzRPTDQzL2gwZ3BtN25nUGIKdWJlLzFFTDRUMHFRbm1Xc2FEOFZ6VStoRXFGSDRTVUtMaDVNeklGbUxFZzRlZ0xCbTBXcWdxbzZRQVBnZDVPYgoybXhmQndta3RLVm5hcWR6eG9KSytzaTVuZkYreGpxbWRMZThUdmEyTHNuTUxlZmsrODVoQ3hxS2x1eWRta1lXCjlvUk5qcDhiQWdNQkFBRUNnZ0VBQkZOUS9NSVZaV2gxdlZUMFh3TFBvUEkyZjI4TTBrM0gzN0t4MXBxK2t5QzYKenRyK1pBczBCaEFEWjAwNHJOUmRYaG45N0QxVXBJYVdLeUJFZkNZQUEzWmxneS9WQmdGR21sR3VuMWNvdGdXUQoyYzg0SWhLdzNzVFFqL2dJWUxOelFWMTBLUTJYd0JZVHZ1MWhjRFpLeUxCUGJTQ1F4cEhQUGdVcUNRNFljR3lFClErVmc1dHJUYk8wQ2xCZ1U5bkVnYU1RakRJZ0F3WVZPV203dUxJTW84UC9nT3FuT2tmaFhzdzl3VTJVYWxFeTEKRmRZbGhMbGJ0ZS9MZ3lkYlJ2RStjNEtqZVp0Z3ptc1RneEh2dzM5YVVmZUZTclFRT0FjcXc0alNzUjdMck9UZAp5bDhpelRrZVBrTVFMamFqR0pabWdPbitkRzhtUlpMa3FKcWdGaVpqRVFLQmdRRFV0L0xlU0h5SmhvY3VFL240CkZreEpaclJoWUVsWnc2WlZJUnQzWDlPQ1Nmaklab3I1ZkZlczhvUzZySFhKdGZYeWx4QUxOSjJjTUhKTTViVnUKbUFSUFU4cThBeVc0OE03cHAyNmtVVTMxNXc2OU1SUkhzbWgyekRabEtDeG5GM1NSQ3U4YW95d3hZc3RUZ3hkTgo2bDhLNHZsS1dsN3FYblBhWjZjb3lQSU9od0tCZ1FESENuRmRRdW5SMVI2dkxGaVFZMTRiT3QwT0tzVGJYMUJyCmpvUGZySkxvRm5mSCs4VDVnNUdxYkV5T2p0WG1tRXhmTFFpcDBQVXRtc1E0YXlJRFBZYWZtU3RpK2dtQXZFd1MKZTlKcVYxYlRuazUrYnVRZ2FlOW16REpJWkxaczRJUlhrd1Q5aDZ4Q2xKeS80TGJSRHdBU3dUVGJlY01hN3A4UgpQN0p0bjdsYnpRS0JnQzNOR2FjUTFuZktGb3N1VS9FOTQ5a2VHeEtvWjhMREpLcEp3WjgzYTlRdTF6bFhFdTlhCi9ZbklnaG1yam9VSy85VG0vOVpaMHVIUmNKcnNEdCtzTGFsaThsRC9JSDBzcEhDYzAyN2Y3cmhXc3M2N3BaRTIKY2RXNmJLL2xNWUpWQTQxRFhHNVEyZkFjUklsTHZaWFNNL3FsR21ZUEJVYlRaWUNPTnVqS000dzdBb0dBU1dBdwpLcEZnWVZxUDFVUWo0aGEvdW9vWXRBQlFVZzd4TnJWektDSVdoampDTDVkQkpqcTZtSGtVUC9tb0lUcEQ3VkpNCnYwMnBGUWJaRDNOdk5vS1gvbjRZNElRTXZNaXR3cUtqRDFEalVXQXF6N0ZScUNGbGdDQUc2V2szVnl2dG5kczEKRzhISVgwTXFCaEp4VXVDVXhsVXpoelY4RjVHZ1VsdUpDNkMyVklFQ2dZQkJWSkxpZlNVOTlHWGZtK3dPd0RWcgo2bHZoUFgxOTBGVktWQXY3aVVWTXBwWXg4Y0QxYkcyUjRLT29JbnkxYTlxdjA2ZGFzeGVQOStkVjJVMWU3MWl5CkFXWDRBVHIrYitvSGk2eUk1MXRHRk54RUxiNXZYMVpYM3VNaDlWM29iYUpuSFNjYllpKzBBNjlyRmNuNEZuLzUKWXJybWxLTzRlRHFVZkswbVFJVCtwUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"
|
||||
filter="raw" />
|
||||
</fieldset>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
* VERSION: 02.47.27
|
||||
* VERSION: 02.47.81
|
||||
* PATH: /src/script.php
|
||||
* BRIEF: Installation script for MokoSuiteClient plugin
|
||||
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
* VERSION: 02.47.27
|
||||
* VERSION: 02.47.81
|
||||
* PATH: /src/services/provider.php
|
||||
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
||||
* NOTE: Registers the plugin with Joomla's DI container
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientBackup</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDBIP</namespace>
|
||||
|
||||
|
||||
+2
@@ -15,6 +15,8 @@ PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DELETE_VERSIONS_LABEL="Delete All Versions"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DELETE_VERSIONS_DESC="One-shot: delete all content version history on save. Automatically turns off after execution."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_DLKEYS_LABEL="Reset Download Keys"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_DLKEYS_DESC="One-shot: clear all download keys (dlid) from update sites on save. Automatically turns off after execution."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_TOURS_LABEL="Reset Tour Prompts"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_TOURS_DESC="One-shot: reset all guided tour completion flags on save. Allows tours to re-trigger for all users. Automatically turns off after execution."
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES="Mirror Domains & Staging Environments"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES_DESC="Configure domain aliases that share this site's hosting folder. Each mirror can independently bypass offline mode and control search engine indexing."
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDevTools</namespace>
|
||||
|
||||
@@ -61,6 +61,14 @@
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="reset_tour_prompts" type="radio" default="0"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_TOURS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_TOURS_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="site_aliases"
|
||||
|
||||
@@ -118,6 +118,13 @@ class DevTools extends CMSPlugin implements SubscriberInterface
|
||||
$params->set('reset_download_keys', 0);
|
||||
}
|
||||
|
||||
// Reset tour prompts on save if toggled on
|
||||
if ($params->get('reset_tour_prompts', 0))
|
||||
{
|
||||
$this->resetTourPrompts();
|
||||
$params->set('reset_tour_prompts', 0);
|
||||
}
|
||||
|
||||
// Reset the one-shot toggles
|
||||
if ($table->params !== $params->toString())
|
||||
{
|
||||
@@ -160,6 +167,21 @@ class DevTools extends CMSPlugin implements SubscriberInterface
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function resetTourPrompts(): int
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__user_profiles'))
|
||||
->where($db->quoteName('profile_key') . ' LIKE ' . $db->quote('guidedtours.tour%'))
|
||||
)->execute();
|
||||
|
||||
$count = $db->getAffectedRows();
|
||||
$this->getApplication()->enqueueMessage(\sprintf('Reset %d guided tour completion flags.', $count), 'message');
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function resetDownloadKeys(): int
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientFirewall</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientLicense</namespace>
|
||||
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientOffline</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientTenant</namespace>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>PLG_TASK_MOKOSUITECLIENTDEMO_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php
|
||||
* VERSION: 02.47.27
|
||||
* VERSION: 02.47.81
|
||||
* BRIEF: Content-only snapshot/restore for demo site reset
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>PLG_TASK_MOKOSUITECLIENTSYNC_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php
|
||||
* VERSION: 02.47.27
|
||||
* VERSION: 02.47.81
|
||||
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php
|
||||
* VERSION: 02.47.27
|
||||
* VERSION: 02.47.81
|
||||
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<description>Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info.</description>
|
||||
<namespace path="src">Moko\Plugin\WebServices\MokoSuiteClient</namespace>
|
||||
<files>
|
||||
|
||||
@@ -155,22 +155,5 @@ final class MokoSuiteClientApi extends CMSPlugin implements SubscriberInterface
|
||||
)
|
||||
);
|
||||
|
||||
// Helpdesk Tickets API (#142)
|
||||
$router->createCRUDRoutes(
|
||||
'v1/mokosuiteclient/tickets',
|
||||
'tickets',
|
||||
['component' => 'com_mokosuiteclient']
|
||||
);
|
||||
|
||||
// Ticket reply (custom route — POST only)
|
||||
$router->addRoute(
|
||||
new \Joomla\Router\Route(
|
||||
['POST'],
|
||||
'v1/mokosuiteclient/tickets/:id/reply',
|
||||
'tickets.reply',
|
||||
['id' => '(\d+)'],
|
||||
['component' => 'com_mokosuiteclient']
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteClient</name>
|
||||
<packagename>mokosuiteclient</packagename>
|
||||
<version>02.47.27</version>
|
||||
<version>02.47.81</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+190
-65
@@ -108,6 +108,9 @@ class Pkg_MokosuiteclientInstallerScript
|
||||
// Set up MokoSuiteClient guided tours and unpublish Joomla defaults
|
||||
$this->setupGuidedTours();
|
||||
|
||||
// Register MokoSuiteClient guided tour content (tours + steps)
|
||||
$this->registerGuidedTours();
|
||||
|
||||
// Clean up orphaned empty-element rows and stale files from old DEFAULT '' bug
|
||||
$this->cleanupEmptyElements();
|
||||
|
||||
@@ -1486,97 +1489,217 @@ class Pkg_MokosuiteclientInstallerScript
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
// Define MokoSuiteClient tours
|
||||
// Remove old-format tours (superseded by com_mokosuiteclient.* UIDs)
|
||||
$oldUids = [
|
||||
$db->quote('mokosuiteclient-welcome'),
|
||||
$db->quote('mokosuiteclient-firewall'),
|
||||
$db->quote('mokosuiteclient-extensions'),
|
||||
];
|
||||
|
||||
// Delete orphaned steps first
|
||||
$subQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__guidedtours'))
|
||||
->where($db->quoteName('uid') . ' IN (' . implode(',', $oldUids) . ')');
|
||||
$db->setQuery($subQuery);
|
||||
$oldTourIds = $db->loadColumn();
|
||||
|
||||
if (!empty($oldTourIds))
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__guidedtour_steps'))
|
||||
->where($db->quoteName('tour_id') . ' IN (' . implode(',', array_map('intval', $oldTourIds)) . ')')
|
||||
)->execute();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__guidedtours'))
|
||||
->where($db->quoteName('uid') . ' IN (' . implode(',', $oldUids) . ')')
|
||||
)->execute();
|
||||
}
|
||||
|
||||
// Tour registration is now handled by registerGuidedTours()
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Guided tours setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register MokoSuiteClient guided tours and their steps.
|
||||
*
|
||||
* Inserts tour definitions into #__guidedtours and step definitions into
|
||||
* #__guidedtour_steps. Skips if the tables do not exist (pre-Joomla 4.3)
|
||||
* or if a tour with the same uid already exists.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.47.09
|
||||
*/
|
||||
private function registerGuidedTours(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Check if #__guidedtours table exists (Joomla 4.3+)
|
||||
$tables = $db->getTableList();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
if (!\in_array($prefix . 'guidedtours', $tables, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
// Define tours
|
||||
$tours = [
|
||||
[
|
||||
'uid' => 'mokosuiteclient-welcome',
|
||||
'title' => 'Welcome to MokoSuiteClient',
|
||||
'desc' => 'Get started with the MokoSuiteClient Admin Tools Suite. This tour shows you the key areas of your admin dashboard.',
|
||||
'url' => 'administrator/index.php?option=com_mokosuiteclient',
|
||||
'steps' => [
|
||||
['title' => 'MokoSuiteClient Dashboard', 'desc' => 'This is your MokoSuiteClient control center. You can see site info, feature plugins, WAF activity, and quick actions all in one place.', 'target' => '#mokosuiteclient-dashboard', 'type' => 0],
|
||||
['title' => 'Site Information', 'desc' => 'The info bar shows your Joomla version, PHP version, database type, and debug/offline status at a glance.', 'target' => '.mokosuiteclient-info-bar', 'type' => 0],
|
||||
['title' => 'Quick Actions', 'desc' => 'Use these buttons to clear cache, check updates, manage extensions, and perform common admin tasks with one click.', 'target' => '#mokosuiteclient-btn-cache', 'type' => 0],
|
||||
['title' => 'Feature Plugins', 'desc' => 'MokoSuiteClient features are split into toggleable plugins. Enable or disable security, tenant restrictions, developer tools, and more from here.', 'target' => '.mokosuiteclient-plugin-grid', 'type' => 0],
|
||||
['title' => 'MokoSuiteClient Menu', 'desc' => 'The MokoSuiteClient sidebar menu gives you quick access to all admin tools — Helpdesk, Extensions, WAF Log, Database Tools, and more.', 'target' => '.mokosuiteclient-admin-menu, [class*="mokosuiteclient"]', 'type' => 0],
|
||||
'uid' => 'com_mokosuiteclient.welcome',
|
||||
'title' => 'MokoSuite Welcome',
|
||||
'description' => 'Get started with MokoSuite — configure your health token, send your first heartbeat, and set up trusted IPs.',
|
||||
'extensions' => '["com_mokosuiteclient"]',
|
||||
'url' => 'administrator/index.php?option=com_mokosuiteclient',
|
||||
'steps' => [
|
||||
[
|
||||
'title' => 'Welcome to MokoSuite',
|
||||
'description' => 'This is your MokoSuite control panel. Let\'s walk through the key features.',
|
||||
'target' => '#mokosuiteclient-dashboard',
|
||||
'type' => 2,
|
||||
'position' => 'bottom',
|
||||
],
|
||||
[
|
||||
'title' => 'Site Info Bar',
|
||||
'description' => 'Your site name, version, support PIN, and system info at a glance.',
|
||||
'target' => '.card.mb-4:first-child',
|
||||
'type' => 2,
|
||||
'position' => 'bottom',
|
||||
],
|
||||
[
|
||||
'title' => 'Quick Actions',
|
||||
'description' => 'Clear cache, check updates, manage extensions, and more.',
|
||||
'target' => '#mokosuiteclient-btn-cache',
|
||||
'type' => 2,
|
||||
'position' => 'right',
|
||||
],
|
||||
[
|
||||
'title' => 'Plugin Cards',
|
||||
'description' => 'Enable, disable, and configure MokoSuite plugins from here.',
|
||||
'target' => '.mokosuiteclient-plugin-card:first-child',
|
||||
'type' => 2,
|
||||
'position' => 'top',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'uid' => 'mokosuiteclient-firewall',
|
||||
'title' => 'MokoSuiteClient Firewall Setup',
|
||||
'desc' => 'Configure the Web Application Firewall to protect your site from common attacks.',
|
||||
'url' => 'administrator/index.php?option=com_plugins&task=plugin.edit&filter[search]=mokosuiteclient_firewall',
|
||||
'steps' => [
|
||||
['title' => 'Firewall Plugin', 'desc' => 'The MokoSuiteClient Firewall provides 10 security shields including SQL injection, XSS, and malicious user agent detection.', 'target' => '', 'type' => 0],
|
||||
['title' => 'WAF Shields', 'desc' => 'Enable or disable individual WAF shields. Each shield protects against a specific attack vector. All shields are enabled by default.', 'target' => '', 'type' => 0],
|
||||
['title' => 'Security Headers', 'desc' => 'Configure HTTP security headers like X-Frame-Options, Content-Security-Policy, and HSTS to harden your site against browser-based attacks.', 'target' => '', 'type' => 0],
|
||||
['title' => 'IP Blocklist', 'desc' => 'Block specific IP addresses, CIDR ranges, or wildcard patterns. The auto-ban feature automatically blocks IPs that trigger too many WAF alerts.', 'target' => '', 'type' => 0],
|
||||
],
|
||||
],
|
||||
[
|
||||
'uid' => 'mokosuiteclient-extensions',
|
||||
'title' => 'Moko Extensions Manager',
|
||||
'desc' => 'Browse and install Moko Consulting extensions from the built-in catalog.',
|
||||
'url' => 'administrator/index.php?option=com_mokosuiteclient&view=extensions',
|
||||
'steps' => [
|
||||
['title' => 'Extension Catalog', 'desc' => 'Browse all available Moko Consulting extensions. Each card shows the extension name, description, install status, and current version.', 'target' => '', 'type' => 0],
|
||||
['title' => 'Install Extensions', 'desc' => 'Click Install to add an extension from the Moko Consulting repository. Updates are handled through Joomla\'s standard update system.', 'target' => '', 'type' => 0],
|
||||
'uid' => 'com_mokosuiteclient.firewall',
|
||||
'title' => 'MokoSuite Firewall Setup',
|
||||
'description' => 'Configure your Web Application Firewall — trusted IPs, WAF shields, and security headers.',
|
||||
'extensions' => '["com_mokosuiteclient"]',
|
||||
'url' => 'administrator/index.php?option=com_plugins&task=plugin.edit&extension_id=0',
|
||||
'steps' => [
|
||||
[
|
||||
'title' => 'Your Current IP',
|
||||
'description' => 'This shows your IP address. Copy it to add to the Trusted IPs list.',
|
||||
'target' => '#mokosuiteclient-current-ip',
|
||||
'type' => 2,
|
||||
'position' => 'bottom',
|
||||
],
|
||||
[
|
||||
'title' => 'Trusted IPs',
|
||||
'description' => 'Add IPs that should bypass WAF checks — your office, VPN, etc.',
|
||||
'target' => '#jform_params_trusted_ips',
|
||||
'type' => 2,
|
||||
'position' => 'top',
|
||||
],
|
||||
[
|
||||
'title' => 'WAF Shields',
|
||||
'description' => 'Enable protection against SQL injection, XSS, malicious agents, and more.',
|
||||
'target' => '#attrib-waf',
|
||||
'type' => 2,
|
||||
'position' => 'bottom',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($tours as $tourDef)
|
||||
{
|
||||
// Check if tour already exists
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('id')
|
||||
->from($db->quoteName('#__guidedtours'))
|
||||
->where($db->quoteName('uid') . ' = ' . $db->quote($tourDef['uid']))
|
||||
);
|
||||
// Check if tour already exists by uid
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__guidedtours'))
|
||||
->where($db->quoteName('uid') . ' = ' . $db->quote($tourDef['uid']));
|
||||
$db->setQuery($query);
|
||||
$existingId = (int) $db->loadResult();
|
||||
|
||||
if ($db->loadResult())
|
||||
if ($existingId)
|
||||
{
|
||||
continue;
|
||||
// Update existing tour metadata
|
||||
$update = $db->getQuery(true)
|
||||
->update($db->quoteName('#__guidedtours'))
|
||||
->set($db->quoteName('title') . ' = ' . $db->quote($tourDef['title']))
|
||||
->set($db->quoteName('description') . ' = ' . $db->quote($tourDef['description']))
|
||||
->set($db->quoteName('extensions') . ' = ' . $db->quote($tourDef['extensions']))
|
||||
->set($db->quoteName('url') . ' = ' . $db->quote($tourDef['url']))
|
||||
->set($db->quoteName('published') . ' = 1')
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->where($db->quoteName('id') . ' = ' . $existingId);
|
||||
$db->setQuery($update)->execute();
|
||||
|
||||
// Delete existing steps so they are re-inserted fresh
|
||||
$delete = $db->getQuery(true)
|
||||
->delete($db->quoteName('#__guidedtour_steps'))
|
||||
->where($db->quoteName('tour_id') . ' = ' . $existingId);
|
||||
$db->setQuery($delete)->execute();
|
||||
|
||||
$tourId = $existingId;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Insert new tour
|
||||
$tour = (object) [
|
||||
'title' => $tourDef['title'],
|
||||
'uid' => $tourDef['uid'],
|
||||
'description' => $tourDef['description'],
|
||||
'extensions' => $tourDef['extensions'],
|
||||
'url' => $tourDef['url'],
|
||||
'created' => $now,
|
||||
'created_by' => 0,
|
||||
'modified' => $now,
|
||||
'modified_by' => 0,
|
||||
'published' => 1,
|
||||
'language' => '*',
|
||||
'note' => 'MokoSuiteClient',
|
||||
'access' => 1,
|
||||
'ordering' => 0,
|
||||
'autostart' => 0,
|
||||
];
|
||||
|
||||
$db->insertObject('#__guidedtours', $tour, 'id');
|
||||
$tourId = (int) $tour->id;
|
||||
}
|
||||
|
||||
$tour = (object) [
|
||||
'title' => $tourDef['title'],
|
||||
'uid' => $tourDef['uid'],
|
||||
'description' => $tourDef['desc'],
|
||||
'extensions' => '',
|
||||
'url' => $tourDef['url'],
|
||||
'created' => date('Y-m-d H:i:s'),
|
||||
'created_by' => 0,
|
||||
'modified' => date('Y-m-d H:i:s'),
|
||||
'modified_by' => 0,
|
||||
'published' => 1,
|
||||
'language' => '*',
|
||||
'note' => 'MokoSuiteClient',
|
||||
'access' => 3,
|
||||
'ordering' => 0,
|
||||
'autostart' => 0,
|
||||
];
|
||||
|
||||
$db->insertObject('#__guidedtours', $tour, 'id');
|
||||
$tourId = (int) $tour->id;
|
||||
|
||||
// Insert steps
|
||||
foreach ($tourDef['steps'] as $i => $stepDef)
|
||||
{
|
||||
$step = (object) [
|
||||
'tour_id' => $tourId,
|
||||
'title' => $stepDef['title'],
|
||||
'description' => $stepDef['desc'],
|
||||
'description' => $stepDef['description'],
|
||||
'target' => $stepDef['target'],
|
||||
'type' => $stepDef['type'],
|
||||
'interactive_type' => 1,
|
||||
'url' => '',
|
||||
'position' => 'bottom',
|
||||
'position' => $stepDef['position'],
|
||||
'ordering' => $i + 1,
|
||||
'published' => 1,
|
||||
'created' => date('Y-m-d H:i:s'),
|
||||
'created' => $now,
|
||||
'created_by' => 0,
|
||||
'modified' => date('Y-m-d H:i:s'),
|
||||
'modified' => $now,
|
||||
'modified_by' => 0,
|
||||
'language' => '*',
|
||||
'note' => '',
|
||||
@@ -1586,10 +1709,12 @@ class Pkg_MokosuiteclientInstallerScript
|
||||
$db->insertObject('#__guidedtour_steps', $step, 'id');
|
||||
}
|
||||
}
|
||||
|
||||
Log::add('Registered ' . \count($tours) . ' MokoSuiteClient guided tours.', Log::INFO, 'mokosuiteclient');
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Guided tours setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
Log::add('Guided tour registration error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user