Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 247c560510 | |||
| 3390c76719 | |||
| 5d5a7dcdbf | |||
| 76e9624ddf | |||
| 6f7549fa7a | |||
| 91dd1e1eb5 | |||
| 28a4a8cd1b | |||
| dcaaf007cf | |||
| 39837737c5 | |||
| 2a6f5e2d96 | |||
| 9118eb1fb5 | |||
| a295f44c57 | |||
| daaf0a73f6 | |||
| af6165d800 | |||
| 4c4d98cc9f | |||
| cfc70ac712 | |||
| e4558c1642 | |||
| 7e73343b4f | |||
| 29bf75051f | |||
| cca6ae3326 | |||
| d0fc3bdfb1 | |||
| 50c3b621a3 | |||
| 0aeaea208f | |||
| 95e9e618aa | |||
| 112e2810bd | |||
| 4088815485 | |||
| 328acb462b | |||
| ee27f0a3b2 | |||
| e70539f817 | |||
| 4c39c583c0 | |||
| 72da5ca1b5 | |||
| 97670dbd54 | |||
| 4e1b9b8044 | |||
| 220e52fb71 | |||
| c4735a73e8 | |||
| d430e083e4 | |||
| 52ad2f2b37 | |||
| 0308df3b53 | |||
| 21df82dc59 | |||
| 9ad828c248 | |||
| 15e891dca2 | |||
| 6ee0f08f42 | |||
| d0ff29aed2 | |||
| 6186eec2ca | |||
| 4566071d74 | |||
| 4a8570f7a3 | |||
| 756e8b664b | |||
| a8224bce93 | |||
| 711b89ea03 | |||
| 98d7ab3bb3 | |||
| ee4746e8ff | |||
| 6fa3cbbaea | |||
| 0e2433cf5c | |||
| 59f50867c0 | |||
| 941d49e0ce | |||
| ad7af89228 | |||
| 078caa423b | |||
| 1583e98cea | |||
| a737ac9106 | |||
| d03269c1ca | |||
| 29079c10e2 | |||
| b433865a6d | |||
| b3cba3ea78 | |||
| 3e79014b97 | |||
| 62d213027f | |||
| b2d1e4ba23 | |||
| d4c4d1be2d |
@@ -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
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: moko-platform.Automation
|
# INGROUP: moko-platform.Automation
|
||||||
# VERSION: 02.46.80
|
# VERSION: 02.47.77
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
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
|
|
||||||
+38
-6
@@ -14,21 +14,53 @@
|
|||||||
INGROUP: MokoSuiteClient.Documentation
|
INGROUP: MokoSuiteClient.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
PATH: ./CHANGELOG.md
|
PATH: ./CHANGELOG.md
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
BRIEF: Version history using `Keep a Changelog`
|
BRIEF: Version history using `Keep a Changelog`
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [02.44.00] --- 2026-06-20
|
### Added
|
||||||
|
- **Mirror Domains & Staging** — repeatable subform table in DevTools plugin for configuring domain aliases with per-alias offline bypass, robots directive, and labels
|
||||||
|
- **Daily Support PIN** — HMAC-SHA256 rotating PIN shown on cpanel module, component dashboard, and HQ site cards
|
||||||
|
- **Domain as support key** — click-to-copy domain in admin status bar
|
||||||
|
- **Current IP display** — firewall plugin settings show admin's IP with copy button
|
||||||
|
- **Heartbeat monitor** — consolidated into core plugin from retired monitor plugin, with diagnostic logging on all bail-out points
|
||||||
|
- **Backup bridge plugin** — discovers MokoSuiteBackup's BackupStatusHelper and sends status in heartbeat payloads
|
||||||
|
- **Activity log** — blockchain-style hash chain for tamper detection in MokoSuiteHQ
|
||||||
|
- **Dev domain in heartbeat** — client sends dev alias to HQ for display on dashboard
|
||||||
|
- **Login app badges** — recent logins table shows Admin/Site badge parsed from Joomla action log message JSON
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Plugin install** — self-healing: extracts plugin zips from package on every update, creates missing extension records with namespace
|
||||||
|
- **Menu naming** — MokoSuiteClient displays as "MokoSuite", MokoSuiteHQ as "MokoHQ", others stripped of prefix
|
||||||
|
- **Menu ordering** — HQ first, MokoSuite second, others alphabetical
|
||||||
|
- **Cpanel module** — always starts collapsed, access level 3 (Special), pretty plugin badge labels
|
||||||
|
- **Module namespaces** — fixed cpanel (MokoSuiteCpanel → MokoSuiteClientCpanel) and cache (MokoSuiteCache → MokoSuiteClientCache)
|
||||||
|
- **Health checks** — return status:error on exceptions instead of false status:ok; MokoSuiteBackup detection queries correct table
|
||||||
|
- **Heartbeat** — correct URL (suite.dev), correct API route (mokosuitehq), correct headers (X-MokoSuite-*), fresh RSA key pair
|
||||||
|
- **Date formats** — all templates use Joomla locale-aware DATE_FORMAT_LC2/LC4
|
||||||
|
- **Domains** — updated from waas.dev to suite.dev.mokoconsulting.tech throughout
|
||||||
|
- **Dashboard info bar** — reverted stacked layout; info items back to horizontal row
|
||||||
|
- **Extension version bar** — full-width auto-sized strip with equal-width cells and border separators
|
||||||
|
- **Recent logins** — exact match on LOGGED_IN key (excludes logout noise), limit increased to 10
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Helpdesk/tickets** — migrated to MokoSuiteCRM (issue #67)
|
||||||
|
- **Monitor plugin** — retired, config consolidated into core plugin
|
||||||
|
- **Backup bridge** — temporarily removed from package manifest (build pipeline issue)
|
||||||
|
- **Update server migration** — removed migrateUpdateServerUrls, cleanupStaleUpdateSites, fixUpdateRecords, enableUpdateServer calls
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Plugin files installing to group root instead of element subdirectory (ALTER TABLE DEFAULT '' + empty element cleanup)
|
||||||
|
- Orphan extension rows with empty element or display-name-as-element
|
||||||
|
- Module not publishing (ensureAdminModule direct DB update bypasses checked_out)
|
||||||
|
- RSA key pair had Windows line endings causing signature verification failure
|
||||||
|
- Heartbeat connection failing due to wrong domain, route, and header names
|
||||||
|
|
||||||
## [02.44.00] --- 2026-06-20
|
## [02.44.00] --- 2026-06-20
|
||||||
|
|
||||||
## [02.43.00] --- 2026-06-20
|
|
||||||
|
|
||||||
## [02.43.00] --- 2026-06-20
|
|
||||||
|
|
||||||
## [02.42.00] --- 2026-06-20
|
## [02.42.00] --- 2026-06-20
|
||||||
|
|
||||||
## [02.42.00] --- 2026-06-20
|
## [02.42.00] --- 2026-06-20
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Documentation
|
INGROUP: MokoSuiteClient.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: ./CODE_OF_CONDUCT.md
|
PATH: ./CODE_OF_CONDUCT.md
|
||||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||||
-->
|
-->
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand
|
DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand
|
||||||
INGROUP: MokoStandards.Governance
|
INGROUP: MokoStandards.Governance
|
||||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand
|
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /GOVERNANCE.md
|
PATH: /GOVERNANCE.md
|
||||||
BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand
|
BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand
|
||||||
-->
|
-->
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
INGROUP: MokoSuiteClient.Documentation
|
INGROUP: MokoSuiteClient.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
PATH: ./LICENSE.md
|
PATH: ./LICENSE.md
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
BRIEF: Project license (GPL-3.0-or-later)
|
BRIEF: Project license (GPL-3.0-or-later)
|
||||||
-->
|
-->
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient
|
INGROUP: MokoSuiteClient
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /README.md
|
PATH: /README.md
|
||||||
BRIEF: MokoSuiteClient platform plugin for Joomla
|
BRIEF: MokoSuiteClient platform plugin for Joomla
|
||||||
-->
|
-->
|
||||||
@@ -21,17 +21,40 @@
|
|||||||
[](https://www.joomla.org)
|
[](https://www.joomla.org)
|
||||||
[](https://www.php.net)
|
[](https://www.php.net)
|
||||||
|
|
||||||
MokoSuiteClient is a Joomla 5.x / 6.x system plugin package that provides white-label branding, security hardening, tenant restrictions, health monitoring, and multi-domain management for the MokoSuiteClient platform.
|
MokoSuiteClient is the Joomla 5.x / 6.x client-facing tracker and identity layer for the MokoSuite platform. It provides security hardening, health monitoring, privacy compliance, multi-domain management, and integration with MokoSuiteHQ for centralized site management.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **White-Label Branding** — configurable brand name, company, support URL, colors, favicon, custom CSS
|
### Core
|
||||||
- **Tenant Restrictions** — master user enforcement, installer/sysinfo/config/template access control
|
- **Admin Dashboard** — site info, plugin status, quick actions, support PIN
|
||||||
- **Health Monitoring** — 16 diagnostic checks via `/?mokosuiteclient=health` with Grafana auto-provisioning
|
- **Health Monitoring** — 15 diagnostic checks via `/?mokosuiteclient=health`
|
||||||
- **Site Aliases** — per-alias offline mode, robots directives, backend redirect, canonical URLs
|
- **Heartbeat** — RSA-signed registration with MokoSuiteHQ, daily support PIN rotation
|
||||||
- **Remote API** — 6 endpoints (health, install, update, cache, backup, info)
|
- **Extension Catalog** — browse and install Moko Consulting extensions
|
||||||
- **Security Hardening** — HTTPS enforcement, session timeouts, password policy, upload restrictions
|
|
||||||
- **Plugin Protection** — protected status, hidden from non-master users, disable/uninstall blocked
|
### Security (Firewall Plugin)
|
||||||
|
- **Web Application Firewall** — SQL injection, XSS, RFI, directory traversal shields
|
||||||
|
- **Security Headers** — X-Frame-Options, CSP, HSTS, Referrer-Policy, Permissions-Policy
|
||||||
|
- **IP Management** — trusted IPs, blocklist, auto-ban on WAF threshold
|
||||||
|
- **Password Policy** — min length, uppercase, number, special character requirements
|
||||||
|
- **Access Control** — admin secret URL, frontend super user block, upload restrictions
|
||||||
|
|
||||||
|
### Privacy Guard
|
||||||
|
- **GDPR Compliance** — data subject requests, consent logging, retention policies
|
||||||
|
- **User Data** — export, anonymize, or delete user data on request
|
||||||
|
|
||||||
|
### DevTools
|
||||||
|
- **Development Mode** — debug, cache disable, hit suppression
|
||||||
|
- **Mirror Domains & Staging** — repeatable table of domain aliases with offline bypass and robots directives
|
||||||
|
- **Maintenance** — reset hits, delete versions, reset download keys
|
||||||
|
|
||||||
|
### Multi-Domain
|
||||||
|
- **Site Aliases** — per-alias offline mode, robots directives, canonical URLs
|
||||||
|
- **Offline Bypass** — TOS, privacy policy, and support pages remain accessible when site is offline
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
- **MokoSuiteHQ** — heartbeat, health data, backup status, activity logging
|
||||||
|
- **MokoSuiteBackup** — bridge plugin discovers BackupStatusHelper for heartbeat payloads
|
||||||
|
- **Joomla** — guided tours, action logging, custom fields, scheduled tasks
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
|||||||
INGROUP: [PROJECT_NAME].Documentation
|
INGROUP: [PROJECT_NAME].Documentation
|
||||||
REPO: [REPOSITORY_URL]
|
REPO: [REPOSITORY_URL]
|
||||||
PATH: /SECURITY.md
|
PATH: /SECURITY.md
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
BRIEF: Security vulnerability reporting and handling policy
|
BRIEF: Security vulnerability reporting and handling policy
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
INGROUP: MokoSuiteClient.Build
|
INGROUP: MokoSuiteClient.Build
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
FILE: build-guide.md
|
FILE: build-guide.md
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /docs/guides/
|
PATH: /docs/guides/
|
||||||
BRIEF: Build and packaging guide for the MokoSuiteClient system plugin
|
BRIEF: Build and packaging guide for the MokoSuiteClient system plugin
|
||||||
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Build Guide (VERSION: 02.46.80)
|
# MokoSuiteClient Build Guide (VERSION: 02.47.77)
|
||||||
|
|
||||||
## 1. Purpose
|
## 1. Purpose
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /docs/guides/configuration-guide.md
|
PATH: /docs/guides/configuration-guide.md
|
||||||
BRIEF: Configuration guide for the MokoSuiteClient system plugin
|
BRIEF: Configuration guide for the MokoSuiteClient system plugin
|
||||||
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Configuration Guide (VERSION: 02.46.80)
|
# MokoSuiteClient Configuration Guide (VERSION: 02.47.77)
|
||||||
|
|
||||||
## 1. Objective
|
## 1. Objective
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /docs/guides/installation-guide.md
|
PATH: /docs/guides/installation-guide.md
|
||||||
BRIEF: Installation guide for the MokoSuiteClient system plugin
|
BRIEF: Installation guide for the MokoSuiteClient system plugin
|
||||||
NOTE: First document in the guide set
|
NOTE: First document in the guide set
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Installation Guide (VERSION: 02.46.80)
|
# MokoSuiteClient Installation Guide (VERSION: 02.47.77)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /docs/guides/operations-guide.md
|
PATH: /docs/guides/operations-guide.md
|
||||||
BRIEF: Operational guide for administering and managing the MokoSuiteClient system plugin
|
BRIEF: Operational guide for administering and managing the MokoSuiteClient system plugin
|
||||||
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Operations Guide (VERSION: 02.46.80)
|
# MokoSuiteClient Operations Guide (VERSION: 02.47.77)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /docs/guides/rollback-and-recovery-guide.md
|
PATH: /docs/guides/rollback-and-recovery-guide.md
|
||||||
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
|
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
|
||||||
NOTE: Completes the core guide set for Suite plugin governance
|
NOTE: Completes the core guide set for Suite plugin governance
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.46.80)
|
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.47.77)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /docs/guides/testing-guide.md
|
PATH: /docs/guides/testing-guide.md
|
||||||
BRIEF: Testing guide for MokoSuiteClient v02.01.08
|
BRIEF: Testing guide for MokoSuiteClient v02.01.08
|
||||||
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Testing Guide (VERSION: 02.46.80)
|
# MokoSuiteClient Testing Guide (VERSION: 02.47.77)
|
||||||
|
|
||||||
## 1. Prerequisites
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /docs/guides/troubleshooting-guide.md
|
PATH: /docs/guides/troubleshooting-guide.md
|
||||||
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuiteClient plugin
|
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuiteClient plugin
|
||||||
NOTE: Designed for administrators and Suite operations teams
|
NOTE: Designed for administrators and Suite operations teams
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.46.80)
|
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.47.77)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
||||||
BRIEF: Guide for updating, versioning, and maintaining the MokoSuiteClient plugin
|
BRIEF: Guide for updating, versioning, and maintaining the MokoSuiteClient plugin
|
||||||
NOTE: Defines release flow, version rules, and upgrade validation
|
NOTE: Defines release flow, version rules, and upgrade validation
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.46.80)
|
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.47.77)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Documentation
|
INGROUP: MokoSuiteClient.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
PATH: /docs/index.md
|
PATH: /docs/index.md
|
||||||
BRIEF: Master index of all documentation for the MokoSuiteClient plugin
|
BRIEF: Master index of all documentation for the MokoSuiteClient plugin
|
||||||
NOTE: Automatically maintained index for all guide canvases
|
NOTE: Automatically maintained index for all guide canvases
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Documentation Index (VERSION: 02.46.80)
|
# MokoSuiteClient Documentation Index (VERSION: 02.47.77)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
INGROUP: MokoSuiteClient
|
INGROUP: MokoSuiteClient
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
PATH: /docs/plugin-basic.md
|
PATH: /docs/plugin-basic.md
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
BRIEF: Baseline documentation for the MokoSuiteClient system plugin
|
BRIEF: Baseline documentation for the MokoSuiteClient system plugin
|
||||||
NOTE: Foundational reference for internal and external stakeholders
|
NOTE: Foundational reference for internal and external stakeholders
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Plugin Overview (VERSION: 02.46.80)
|
# MokoSuiteClient Plugin Overview (VERSION: 02.47.77)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ DEFGROUP: MokoSuiteClient.Documentation
|
|||||||
INGROUP: MokoStandards.Templates
|
INGROUP: MokoStandards.Templates
|
||||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient
|
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient
|
||||||
PATH: /docs/update-server.md
|
PATH: /docs/update-server.md
|
||||||
VERSION: 02.46.80
|
VERSION: 02.47.77
|
||||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<access component="com_mokosuiteclient">
|
<access component="com_mokosuiteclient">
|
||||||
<section name="component">
|
<section name="component">
|
||||||
|
<!-- Core Joomla ACL -->
|
||||||
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
|
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
|
||||||
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_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.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.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" />
|
<action name="mokosuiteclient.cache" title="COM_MOKOSUITECLIENT_ACL_CACHE" description="COM_MOKOSUITECLIENT_ACL_CACHE_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>
|
</section>
|
||||||
</access>
|
</access>
|
||||||
|
|||||||
@@ -1,122 +1,219 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!--
|
<!--
|
||||||
Extension catalog for MokoSuiteClient Extension Manager.
|
MokoSuite Extension Catalog
|
||||||
Each entry points to the extension's own updates.xml. The installer
|
Each entry points to the extension's updates.xml on the main branch.
|
||||||
resolves the latest version and download URL at runtime, respecting
|
The installer resolves the latest version and download URL at runtime,
|
||||||
the site's configured update channel (dev/stable).
|
respecting the site's configured update channel (stable/dev) from
|
||||||
|
Joomla's com_installer params.
|
||||||
To add an extension: copy an <extension> block and fill in the fields.
|
|
||||||
-->
|
-->
|
||||||
<catalog>
|
<catalog>
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════
|
||||||
|
Platform (Layer 0)
|
||||||
|
═══════════════════════════════════════════════════════════════════ -->
|
||||||
<extension>
|
<extension>
|
||||||
<name>MokoSuiteClient</name>
|
<name>MokoSuiteClient</name>
|
||||||
<element>pkg_mokosuiteclient</element>
|
<element>pkg_mokosuiteclient</element>
|
||||||
<type>package</type>
|
<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>
|
<icon>icon-shield-alt</icon>
|
||||||
<category>Platform</category>
|
<category>Platform</category>
|
||||||
<article>https://mokoconsulting.tech/support/products/mokosuiteclient-platform</article>
|
|
||||||
<protected>true</protected>
|
<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>
|
||||||
<extension>
|
<extension>
|
||||||
<name>MokoSuiteClientHQ</name>
|
<name>MokoSuiteHQ</name>
|
||||||
<element>pkg_mokosuiteclienthq</element>
|
<element>pkg_mokosuitehq</element>
|
||||||
<type>package</type>
|
<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>
|
<icon>icon-tachometer-alt</icon>
|
||||||
<category>Platform</category>
|
<category>Platform</category>
|
||||||
<article>https://mokoconsulting.tech/support/products/mokosuiteclient-base</article>
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteHQ/raw/branch/main/updates.xml</updateserver>
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientHQ/raw/branch/dev/updates.xml</updateserver>
|
|
||||||
</extension>
|
</extension>
|
||||||
<extension>
|
<extension>
|
||||||
<name>MokoOnyx</name>
|
<name>MokoSuiteBackup</name>
|
||||||
<element>mokoonyx</element>
|
<element>pkg_mokosuitebackup</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>
|
|
||||||
<type>package</type>
|
<type>package</type>
|
||||||
<description>Full-site backup and restore for Joomla — database, files, and configuration.</description>
|
<description>Full-site backup and restore for Joomla — database, files, and configuration.</description>
|
||||||
<icon>icon-archive</icon>
|
<icon>icon-archive</icon>
|
||||||
<category>Tools</category>
|
<category>Platform</category>
|
||||||
<article>https://mokoconsulting.tech/support/products/mokosuiteclientbackup</article>
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/raw/branch/main/updates.xml</updateserver>
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientBackup/raw/branch/dev/updates.xml</updateserver>
|
|
||||||
</extension>
|
</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>
|
<extension>
|
||||||
<name>MokoJoomHero</name>
|
<name>MokoJoomHero</name>
|
||||||
<element>mod_mokojoomhero</element>
|
<element>mod_mokojoomhero</element>
|
||||||
<type>module</type>
|
<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>
|
<icon>icon-image</icon>
|
||||||
<category>Modules</category>
|
<category>Modules</category>
|
||||||
<article>https://mokoconsulting.tech/support/products/mokojoomhero</article>
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/main/updates.xml</updateserver>
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/dev/updates.xml</updateserver>
|
|
||||||
</extension>
|
</extension>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════
|
||||||
|
Templates
|
||||||
|
═══════════════════════════════════════════════════════════════════ -->
|
||||||
<extension>
|
<extension>
|
||||||
<name>MokoJoomCommunity</name>
|
<name>MokoOnyx</name>
|
||||||
<element>pkg_mokojoomcommunity</element>
|
<element>mokoonyx</element>
|
||||||
<type>package</type>
|
<type>template</type>
|
||||||
<description>Community Builder integration package with custom fields and user management.</description>
|
<description>Modern Joomla site template with dark mode, custom layouts, and MokoSuite integration.</description>
|
||||||
<icon>icon-users</icon>
|
<icon>icon-paint-brush</icon>
|
||||||
<category>Community</category>
|
<category>Templates</category>
|
||||||
<article>https://mokoconsulting.tech/support/products/mokojoomcommunity</article>
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml</updateserver>
|
||||||
<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>
|
|
||||||
</extension>
|
</extension>
|
||||||
</catalog>
|
</catalog>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<config>
|
<config>
|
||||||
<fieldset name="general" label="General" description="General component settings.">
|
<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"
|
label="Brand Name"
|
||||||
description="Displayed in the admin sidebar, dashboard, and emails."
|
description="Displayed in the admin sidebar, dashboard, and emails."
|
||||||
hint="MokoSuiteClient" />
|
hint="MokoSuite" />
|
||||||
<field name="support_email" type="email" default=""
|
<field name="support_email" type="email" default=""
|
||||||
label="Support Email"
|
label="Support Email"
|
||||||
description="Reply-to address for outbound notification emails."
|
description="Reply-to address for outbound notification emails."
|
||||||
hint="support@example.com" />
|
hint="support@example.com" />
|
||||||
</fieldset>
|
</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=""
|
<field name="admin_emails" type="text" default=""
|
||||||
label="Admin Email Addresses"
|
label="Admin Email Addresses"
|
||||||
description="Comma-separated email addresses to receive all notifications."
|
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="spacer_ntfy" type="spacer" label="Push Notifications (ntfy)" />
|
||||||
<field name="ntfy_enabled" type="radio" default="0"
|
<field name="ntfy_enabled" type="radio" default="0"
|
||||||
label="Enable ntfy Push"
|
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">
|
class="btn-group btn-group-yesno">
|
||||||
<option value="1">JYES</option>
|
<option value="1">JYES</option>
|
||||||
<option value="0">JNO</option>
|
<option value="0">JNO</option>
|
||||||
@@ -40,13 +40,13 @@
|
|||||||
label="ntfy Server URL"
|
label="ntfy Server URL"
|
||||||
description="Full URL to your ntfy server."
|
description="Full URL to your ntfy server."
|
||||||
showon="ntfy_enabled:1" />
|
showon="ntfy_enabled:1" />
|
||||||
<field name="ntfy_topic" type="text" default="mokosuiteclient-tickets"
|
<field name="ntfy_topic" type="text" default="mokosuite-alerts"
|
||||||
label="Ticket Topic"
|
label="Alert Topic"
|
||||||
description="ntfy topic name for helpdesk ticket notifications."
|
description="ntfy topic name for general alert notifications."
|
||||||
showon="ntfy_enabled:1" />
|
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"
|
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" />
|
showon="ntfy_enabled:1" />
|
||||||
<field name="ntfy_token" type="password" default=""
|
<field name="ntfy_token" type="password" default=""
|
||||||
label="ntfy Auth Token"
|
label="ntfy Auth Token"
|
||||||
@@ -54,59 +54,42 @@
|
|||||||
showon="ntfy_enabled:1" />
|
showon="ntfy_enabled:1" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
|
<fieldset name="content_tools" label="Content Tools" description="Settings for content tag engines and replacements.">
|
||||||
<field name="default_category" type="sql" default=""
|
<field name="spacer_snippets" type="spacer" label="Snippets" />
|
||||||
label="Default Ticket Category"
|
<field name="snippets_default_category" type="text" default=""
|
||||||
description="Category assigned to tickets without a selection."
|
label="Default Snippet Category"
|
||||||
query="SELECT id AS value, title AS text FROM #__mokosuiteclient_ticket_categories WHERE published = 1 ORDER BY ordering" />
|
description="Category assigned to new snippets if none selected." />
|
||||||
<field name="autoclose_days" type="number" default="7"
|
|
||||||
label="Auto-Close After (days)"
|
<field name="spacer_templates" type="spacer" label="Content Templates" />
|
||||||
description="Resolved tickets are auto-closed after this many days. 0 = disabled." />
|
<field name="templates_default_category" type="text" default=""
|
||||||
<field name="kb_search_enabled" type="radio" default="1"
|
label="Default Template Category"
|
||||||
label="KB Search on Ticket Forms"
|
description="Category assigned to new content templates if none selected." />
|
||||||
description="Show knowledge base search before ticket submission."
|
|
||||||
class="btn-group btn-group-yesno">
|
<field name="spacer_replacements" type="spacer" label="Replacements" />
|
||||||
<option value="1">JYES</option>
|
<field name="replacements_max_rules" type="number" default="100"
|
||||||
<option value="0">JNO</option>
|
label="Max Active Rules"
|
||||||
</field>
|
description="Maximum number of replacement rules processed per page load. 0 = unlimited." />
|
||||||
<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>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="email_to_ticket" label="Email-to-Ticket (IMAP)" description="Create tickets from incoming emails via IMAP polling.">
|
<fieldset name="impersonation" label="User Impersonation" description="Skeleton Key — log into the frontend as another user for support.">
|
||||||
<field name="imap_host" type="text" default=""
|
<field name="skeleton_key_control_groups" type="usergrouplist" default="8"
|
||||||
label="IMAP Server"
|
label="Groups Allowed to Impersonate"
|
||||||
description="IMAP hostname (e.g. imap.gmail.com)"
|
description="User groups that can log in as another user."
|
||||||
hint="imap.gmail.com" />
|
multiple="true"
|
||||||
<field name="imap_port" type="number" default="993"
|
layout="joomla.form.field.list-fancy-select" />
|
||||||
label="Port"
|
<field name="skeleton_key_target_groups" type="usergrouplist" default="2"
|
||||||
description="IMAP port (993 for SSL, 143 for plain)" />
|
label="Groups That Can Be Impersonated"
|
||||||
<field name="imap_ssl" type="radio" default="1"
|
description="User groups whose accounts can be accessed via impersonation."
|
||||||
label="Use SSL"
|
multiple="true"
|
||||||
class="btn-group btn-group-yesno">
|
layout="joomla.form.field.list-fancy-select" />
|
||||||
<option value="1">JYES</option>
|
<field name="skeleton_key_blocked_groups" type="usergrouplist" default="7,8"
|
||||||
<option value="0">JNO</option>
|
label="Groups That Cannot Be Impersonated"
|
||||||
</field>
|
description="User groups protected from impersonation (overrides target groups)."
|
||||||
<field name="imap_user" type="text" default=""
|
multiple="true"
|
||||||
label="Username"
|
layout="joomla.form.field.list-fancy-select" />
|
||||||
description="IMAP login username or email address." />
|
<field name="skeleton_key_cookie_lifetime" type="number" default="10"
|
||||||
<field name="imap_password" type="password" default=""
|
label="Cookie Lifetime (seconds)"
|
||||||
label="Password"
|
description="How long the impersonation cookie remains valid. Short values are more secure." />
|
||||||
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>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="permissions" label="COM_MOKOSUITECLIENT_ACL_TITLE"
|
<fieldset name="permissions" label="COM_MOKOSUITECLIENT_ACL_TITLE"
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
; MokoSuiteClient Admin Dashboard - Language Strings
|
; MokoSuite Admin Dashboard - Language Strings
|
||||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
; License: GPL-3.0-or-later
|
; License: GPL-3.0-or-later
|
||||||
|
|
||||||
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuiteClient Control Panel"
|
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuite Control Panel"
|
||||||
|
|
||||||
|
; 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_SITE="Site"
|
||||||
COM_MOKOSUITECLIENT_DATABASE="Database"
|
COM_MOKOSUITECLIENT_DATABASE="Database"
|
||||||
COM_MOKOSUITECLIENT_DEBUG_ON="Debug ON"
|
COM_MOKOSUITECLIENT_DEBUG_ON="Debug ON"
|
||||||
@@ -20,22 +23,27 @@ 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_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_EXTENSIONS_LINK="Moko Extensions"
|
||||||
COM_MOKOSUITECLIENT_HTACCESS_TITLE=".htaccess Maker"
|
COM_MOKOSUITECLIENT_HTACCESS_TITLE=".htaccess Maker"
|
||||||
COM_MOKOSUITECLIENT_TICKETS_TITLE="Helpdesk"
|
|
||||||
|
|
||||||
; ACL
|
; 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="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="Manage Extensions"
|
||||||
COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko 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="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="Clear Cache"
|
||||||
COM_MOKOSUITECLIENT_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
|
COM_MOKOSUITECLIENT_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
|
||||||
|
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."
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@
|
|||||||
; License: GPL-3.0-or-later
|
; License: GPL-3.0-or-later
|
||||||
|
|
||||||
COM_MOKOSUITECLIENT="MokoSuite"
|
COM_MOKOSUITECLIENT="MokoSuite"
|
||||||
COM_MOKOSUITECLIENT_DESCRIPTION="MokoSuiteClient admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management."
|
COM_MOKOSUITECLIENT_DESCRIPTION="MokoSuite admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management."
|
||||||
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuiteClient Control Panel"
|
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuite Control Panel"
|
||||||
COM_MOKOSUITECLIENT_MENU_DASHBOARD="Dashboard"
|
COM_MOKOSUITECLIENT_MENU_DASHBOARD="Dashboard"
|
||||||
COM_MOKOSUITECLIENT_MENU_EXTENSIONS="Moko Extensions"
|
COM_MOKOSUITECLIENT_MENU_EXTENSIONS="Moko Extensions"
|
||||||
COM_MOKOSUITECLIENT_MENU_PLUGINS="Feature Plugins"
|
COM_MOKOSUITECLIENT_MENU_PLUGINS="Feature Plugins"
|
||||||
|
|||||||
@@ -227,3 +227,126 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_license_cache` (
|
|||||||
PRIMARY KEY (`dlid_hash`),
|
PRIMARY KEY (`dlid_hash`),
|
||||||
KEY `idx_checked` (`checked_at`)
|
KEY `idx_checked` (`checked_at`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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.
|
* ACL map: view name => required permission.
|
||||||
*/
|
*/
|
||||||
private const VIEW_ACL = [
|
private const VIEW_ACL = [
|
||||||
'dashboard' => 'mokosuiteclient.dashboard',
|
'dashboard' => 'mokosuiteclient.dashboard',
|
||||||
'extensions' => 'mokosuiteclient.extensions',
|
'extensions' => 'mokosuiteclient.extensions',
|
||||||
'htaccess' => 'mokosuiteclient.htaccess',
|
'htaccess' => 'mokosuiteclient.htaccess',
|
||||||
'tickets' => 'mokosuiteclient.tickets',
|
'privacy' => 'core.admin',
|
||||||
'ticket' => 'mokosuiteclient.tickets',
|
'waflog' => 'mokosuiteclient.security.waflog',
|
||||||
'privacy' => 'core.admin',
|
'automation' => 'core.admin',
|
||||||
'waflog' => 'core.admin',
|
'database' => 'core.admin',
|
||||||
'categories' => 'mokosuiteclient.tickets',
|
'cleanup' => 'mokosuiteclient.cache',
|
||||||
'canned' => 'mokosuiteclient.tickets',
|
'snippets' => 'mokosuiteclient.snippets.manage',
|
||||||
'automation' => 'core.admin',
|
'templates' => 'mokosuiteclient.templates.manage',
|
||||||
'database' => 'core.admin',
|
'replacements' => 'mokosuiteclient.replacements.manage',
|
||||||
'cleanup' => 'mokosuiteclient.cache',
|
'conditions' => 'mokosuiteclient.conditions.manage',
|
||||||
'ticketsettings' => 'core.admin',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function display($cachable = false, $urlparams = [])
|
public function display($cachable = false, $urlparams = [])
|
||||||
@@ -142,6 +141,22 @@ class DisplayController extends BaseController
|
|||||||
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
|
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
|
||||||
$timestamp = time();
|
$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([
|
$payload = json_encode([
|
||||||
'token' => $healthToken,
|
'token' => $healthToken,
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
@@ -150,6 +165,7 @@ class DisplayController extends BaseController
|
|||||||
'joomla_version' => (new \Joomla\CMS\Version())->getShortVersion(),
|
'joomla_version' => (new \Joomla\CMS\Version())->getShortVersion(),
|
||||||
'php_version' => PHP_VERSION,
|
'php_version' => PHP_VERSION,
|
||||||
'timestamp' => $timestamp,
|
'timestamp' => $timestamp,
|
||||||
|
'moko_packages' => $mokoPackages,
|
||||||
], JSON_UNESCAPED_SLASHES);
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
// RSA sign the request
|
// RSA sign the request
|
||||||
@@ -204,7 +220,7 @@ class DisplayController extends BaseController
|
|||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => 15,
|
CURLOPT_TIMEOUT => 15,
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
CURLOPT_SSL_VERIFYPEER => false,
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
@@ -347,64 +363,7 @@ class DisplayController extends BaseController
|
|||||||
$app->close();
|
$app->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================================================================
|
$input = Factory::getApplication()->getInput();
|
||||||
// Tickets
|
|
||||||
// ==================================================================
|
|
||||||
|
|
||||||
public function createTicket()
|
|
||||||
{
|
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
|
||||||
|
|
||||||
if (!$this->checkAcl('mokosuiteclient.tickets.create'))
|
|
||||||
{
|
|
||||||
$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(
|
$this->jsonResponse($this->getModel('Tickets')->updateStatus(
|
||||||
$input->getInt('ticket_id', 0),
|
$input->getInt('ticket_id', 0),
|
||||||
@@ -412,95 +371,10 @@ class DisplayController extends BaseController
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================================================================
|
|
||||||
// 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);
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
$this->jsonResponse($this->getModel('Tickets')->deletePriority($id));
|
$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
|
try
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
@@ -568,216 +442,83 @@ class DisplayController extends BaseController
|
|||||||
$this->jsonResponse($model->cleanDirectory($dirKey));
|
$this->jsonResponse($model->cleanDirectory($dirKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
|
||||||
|
$this->jsonResponse($this->getModel('Tickets')->updateStatus(
|
||||||
|
$input->getInt('ticket_id', 0),
|
||||||
|
$input->getInt('status', 0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
$this->jsonResponse($this->getModel('Tickets')->deletePriority($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
|
||||||
|
|
||||||
|
$results = $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() ?: [];
|
||||||
|
|
||||||
|
foreach ($results as $r)
|
||||||
|
{
|
||||||
|
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jsonResponse(['results' => $results]);
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('KB search failed: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||||
|
$this->jsonResponse(['results' => [], 'error' => 'Search unavailable']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
// Helpdesk CRUD (#137, #138, #139)
|
// Maintenance (#127, #128)
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
|
|
||||||
public function saveCategory()
|
public function optimizeDb()
|
||||||
{
|
|
||||||
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'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||||
$input = Factory::getApplication()->getInput();
|
$model = new \Moko\Component\MokoSuiteClient\Administrator\Model\MaintenanceModel();
|
||||||
$db = Factory::getDbo();
|
$this->jsonResponse($model->optimizeTables());
|
||||||
$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()
|
public function repairDb()
|
||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||||
$db = Factory::getDbo();
|
$model = new \Moko\Component\MokoSuiteClient\Administrator\Model\MaintenanceModel();
|
||||||
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_automation')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
$this->jsonResponse($model->repairTables());
|
||||||
$this->jsonResponse(['success' => true, 'message' => 'Rule deleted.']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleAutomation()
|
public function purgeSessions()
|
||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||||
$input = Factory::getApplication()->getInput();
|
$model = new \Moko\Component\MokoSuiteClient\Administrator\Model\MaintenanceModel();
|
||||||
$db = Factory::getDbo();
|
$this->jsonResponse($model->purgeSessions());
|
||||||
$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()
|
public function cleanDirectory()
|
||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
if (!$this->checkAcl('mokosuiteclient.cache')) { $this->jsonForbidden(); return; }
|
||||||
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
$dirKey = Factory::getApplication()->getInput()->getString('dir_key', '');
|
||||||
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
$model = new \Moko\Component\MokoSuiteClient\Administrator\Model\MaintenanceModel();
|
||||||
$db = Factory::getDbo();
|
$this->jsonResponse($model->cleanDirectory($dirKey));
|
||||||
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.']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
@@ -891,7 +632,7 @@ class DisplayController extends BaseController
|
|||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
if (!$this->checkAcl('core.admin'))
|
if (!$this->checkAcl('mokosuiteclient.security.waflog'))
|
||||||
{
|
{
|
||||||
$this->jsonForbidden();
|
$this->jsonForbidden();
|
||||||
return;
|
return;
|
||||||
@@ -907,7 +648,7 @@ class DisplayController extends BaseController
|
|||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
if (!$this->checkAcl('core.admin'))
|
if (!$this->checkAcl('mokosuiteclient.security.waflog'))
|
||||||
{
|
{
|
||||||
$this->jsonForbidden();
|
$this->jsonForbidden();
|
||||||
return;
|
return;
|
||||||
@@ -991,19 +732,6 @@ class DisplayController extends BaseController
|
|||||||
// Importers
|
// 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()
|
public function importAdminTools()
|
||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|||||||
@@ -0,0 +1,524 @@
|
|||||||
|
<?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 delimiters if needed.
|
||||||
|
if (@preg_match('#' . $pattern . '#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
|
public function getMokoExtensions(): array
|
||||||
{
|
{
|
||||||
$db = $this->getDatabase();
|
$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)
|
$query = $db->getQuery(true)
|
||||||
->select([
|
->select([
|
||||||
|
$db->quoteName('extension_id'),
|
||||||
$db->quoteName('element'),
|
$db->quoteName('element'),
|
||||||
$db->quoteName('name'),
|
$db->quoteName('name'),
|
||||||
$db->quoteName('type'),
|
$db->quoteName('type'),
|
||||||
|
$db->quoteName('folder'),
|
||||||
$db->quoteName('enabled'),
|
$db->quoteName('enabled'),
|
||||||
$db->quoteName('manifest_cache'),
|
$db->quoteName('manifest_cache'),
|
||||||
])
|
])
|
||||||
->from($db->quoteName('#__extensions'))
|
->from($db->quoteName('#__extensions'))
|
||||||
->where('('
|
->where('(' . implode(' OR ', $patterns) . ')')
|
||||||
// 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%') . ')'
|
|
||||||
. ')')
|
|
||||||
->order($db->quoteName('type') . ' ASC, ' . $db->quoteName('element') . ' ASC');
|
->order($db->quoteName('type') . ' ASC, ' . $db->quoteName('element') . ' ASC');
|
||||||
|
|
||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
@@ -248,12 +264,27 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
{
|
{
|
||||||
$manifest = json_decode($row->manifest_cache ?? '{}');
|
$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) [
|
$extensions[] = (object) [
|
||||||
'element' => $row->element,
|
'extension_id' => (int) $row->extension_id,
|
||||||
'name' => $manifest->name ?? $row->name,
|
'element' => $row->element,
|
||||||
'type' => $row->type,
|
'name' => $manifest->name ?? $row->name,
|
||||||
'version' => $manifest->version ?? '',
|
'type' => $row->type,
|
||||||
'enabled' => (int) $row->enabled,
|
'folder' => $row->folder ?? '',
|
||||||
|
'version' => $manifest->version ?? '',
|
||||||
|
'enabled' => (int) $row->enabled,
|
||||||
|
'family' => $family,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +308,9 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
->select([$db->quoteName('element'), $db->quoteName('protected')])
|
->select([$db->quoteName('element'), $db->quoteName('protected')])
|
||||||
->from($db->quoteName('#__extensions'))
|
->from($db->quoteName('#__extensions'))
|
||||||
->where($db->quoteName('extension_id') . ' = ' . $extensionId)
|
->where($db->quoteName('extension_id') . ' = ' . $extensionId)
|
||||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'));
|
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||||
|
->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient')
|
||||||
|
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokosuiteclient\\_%') . ')');
|
||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
$ext = $db->loadObject();
|
$ext = $db->loadObject();
|
||||||
|
|
||||||
@@ -454,7 +487,7 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
])
|
])
|
||||||
->from($db->quoteName('#__action_logs', 'a'))
|
->from($db->quoteName('#__action_logs', 'a'))
|
||||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.user_id'))
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.user_id'))
|
||||||
->where($db->quoteName('a.message_language_key') . ' LIKE ' . $db->quote('%LOGIN%'))
|
->where($db->quoteName('a.message_language_key') . ' = ' . $db->quote('PLG_ACTIONLOG_JOOMLA_USER_LOGGED_IN'))
|
||||||
->order($db->quoteName('a.log_date') . ' DESC')
|
->order($db->quoteName('a.log_date') . ' DESC')
|
||||||
->setLimit($limit);
|
->setLimit($limit);
|
||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
@@ -568,7 +601,7 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
$db->setQuery(
|
$db->setQuery(
|
||||||
"SELECT DATE(" . $db->quoteName('created') . ") AS day, COUNT(*) AS total"
|
"SELECT DATE(" . $db->quoteName('created') . ") AS day, COUNT(*) AS total"
|
||||||
. " FROM " . $db->quoteName('#__mokosuiteclient_waf_log')
|
. " FROM " . $db->quoteName('#__mokosuiteclient_waf_log')
|
||||||
. " WHERE " . $db->quoteName('created') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)"
|
. " WHERE " . $db->quoteName('created') . " >= DATE_SUB(NOW(), INTERVAL " . (int) $days . " DAY)"
|
||||||
. " GROUP BY day ORDER BY day"
|
. " GROUP BY day ORDER BY day"
|
||||||
);
|
);
|
||||||
$rows = $db->loadObjectList() ?: [];
|
$rows = $db->loadObjectList() ?: [];
|
||||||
@@ -609,7 +642,7 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
"SELECT DATE(" . $db->quoteName('log_date') . ") AS day, COUNT(*) AS total"
|
"SELECT DATE(" . $db->quoteName('log_date') . ") AS day, COUNT(*) AS total"
|
||||||
. " FROM " . $db->quoteName('#__action_logs')
|
. " FROM " . $db->quoteName('#__action_logs')
|
||||||
. " WHERE " . $db->quoteName('message_language_key') . " = 'PLG_ACTIONLOG_JOOMLA_USER_LOGGED_IN'"
|
. " WHERE " . $db->quoteName('message_language_key') . " = 'PLG_ACTIONLOG_JOOMLA_USER_LOGGED_IN'"
|
||||||
. " AND " . $db->quoteName('log_date') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)"
|
. " AND " . $db->quoteName('log_date') . " >= DATE_SUB(NOW(), INTERVAL " . (int) $days . " DAY)"
|
||||||
. " GROUP BY day ORDER BY day"
|
. " GROUP BY day ORDER BY day"
|
||||||
);
|
);
|
||||||
$rows = $db->loadObjectList() ?: [];
|
$rows = $db->loadObjectList() ?: [];
|
||||||
|
|||||||
@@ -238,8 +238,15 @@ class ExtensionsModel extends BaseDatabaseModel
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine site's update channel preference
|
// Dev channel only available on Moko domains; all others forced to stable
|
||||||
$channel = 'dev'; // default to dev — show everything
|
$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;
|
$hasStable = false;
|
||||||
$hasDev = false;
|
$hasDev = false;
|
||||||
|
|
||||||
@@ -269,7 +276,18 @@ class ExtensionsModel extends BaseDatabaseModel
|
|||||||
$hasDev = true;
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,7 +56,7 @@ class HtmlView extends BaseHtmlView
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (\Throwable $e) {}
|
catch (\Throwable $e) {}
|
||||||
$this->recentLogins = $model->getRecentLogins(5);
|
$this->recentLogins = $model->getRecentLogins(10);
|
||||||
$this->pendingUpdates = $model->getPendingUpdates();
|
$this->pendingUpdates = $model->getPendingUpdates();
|
||||||
$this->checkedOutItems = $model->getCheckedOutItems();
|
$this->checkedOutItems = $model->getCheckedOutItems();
|
||||||
$this->wafBlocks = $model->getRecentWafBlocks(5);
|
$this->wafBlocks = $model->getRecentWafBlocks(5);
|
||||||
|
|||||||
@@ -1,72 +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\View\Ticket;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
protected $ticket;
|
|
||||||
protected $cannedResponses = [];
|
|
||||||
protected $statuses = [];
|
|
||||||
protected $priorities = [];
|
|
||||||
protected $customFields = [];
|
|
||||||
protected $fieldValues = [];
|
|
||||||
protected $attachments = [];
|
|
||||||
|
|
||||||
public function display($tpl = null)
|
|
||||||
{
|
|
||||||
$model = $this->getModel('Tickets');
|
|
||||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
|
||||||
|
|
||||||
$this->ticket = $model->getTicket($id);
|
|
||||||
$this->cannedResponses = $model->getCannedResponses((int) ($this->ticket->category_id ?? 0));
|
|
||||||
$this->statuses = $model->getStatuses();
|
|
||||||
$this->priorities = $model->getPriorities();
|
|
||||||
|
|
||||||
// Load custom fields for this ticket's category
|
|
||||||
if ($this->ticket && $this->ticket->category_id)
|
|
||||||
{
|
|
||||||
$groups = $model->getFieldGroupsForCategory((int) $this->ticket->category_id);
|
|
||||||
$groupIds = array_column($groups, 'id');
|
|
||||||
$this->customFields = $model->getFieldsForGroups($groupIds);
|
|
||||||
$this->fieldValues = $model->getFieldValues($id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load attachments
|
|
||||||
$this->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id);
|
|
||||||
|
|
||||||
if (!$this->ticket)
|
|
||||||
{
|
|
||||||
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
|
||||||
Factory::getApplication()->redirect('index.php?option=com_mokosuiteclient&view=tickets');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->addToolbar();
|
|
||||||
|
|
||||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
|
||||||
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
|
||||||
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function addToolbar(): void
|
|
||||||
{
|
|
||||||
$title = $this->ticket ? 'Ticket #' . $this->ticket->id . ' — ' . $this->ticket->subject : 'Ticket';
|
|
||||||
ToolbarHelper::title($title, 'headphones');
|
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +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\View\Tickets;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
protected $tickets = [];
|
|
||||||
protected $categories = [];
|
|
||||||
protected $statusCounts;
|
|
||||||
protected $overdue = [];
|
|
||||||
protected $atsAvailable = null;
|
|
||||||
protected $contacts = [];
|
|
||||||
protected $statuses = [];
|
|
||||||
protected $priorities = [];
|
|
||||||
protected $backendUsers = [];
|
|
||||||
protected $userGroups = [];
|
|
||||||
|
|
||||||
public function display($tpl = null)
|
|
||||||
{
|
|
||||||
$model = $this->getModel();
|
|
||||||
$app = Factory::getApplication();
|
|
||||||
|
|
||||||
$filters = [
|
|
||||||
'status_id' => $app->getInput()->getInt('filter_status', 0),
|
|
||||||
'priority_id' => $app->getInput()->getInt('filter_priority', 0),
|
|
||||||
'category_id' => $app->getInput()->getInt('filter_category', 0),
|
|
||||||
'contact_id' => $app->getInput()->getInt('filter_contact', 0),
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->tickets = $model->getTickets($filters);
|
|
||||||
$this->categories = $model->getCategories();
|
|
||||||
$this->statuses = $model->getStatuses();
|
|
||||||
$this->priorities = $model->getPriorities();
|
|
||||||
$this->statusCounts = $model->getStatusCounts();
|
|
||||||
$this->overdue = $model->getOverdueTickets();
|
|
||||||
$this->atsAvailable = $model->checkAtsAvailable();
|
|
||||||
$this->contacts = $model->getContacts();
|
|
||||||
$this->backendUsers = $model->getBackendUsers();
|
|
||||||
$this->userGroups = $model->getUserGroups();
|
|
||||||
|
|
||||||
$this->addToolbar();
|
|
||||||
|
|
||||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
|
||||||
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
|
||||||
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function addToolbar(): void
|
|
||||||
{
|
|
||||||
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_TICKETS_TITLE'), 'headphones');
|
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
*
|
|
||||||
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
|
||||||
*
|
|
||||||
* @package MokoSuiteClient
|
|
||||||
* @subpackage Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoSuiteClient\Administrator\View\Ticketsettings;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
protected $statuses = [];
|
|
||||||
protected $priorities = [];
|
|
||||||
|
|
||||||
public function display($tpl = null)
|
|
||||||
{
|
|
||||||
$model = $this->getModel('Tickets');
|
|
||||||
|
|
||||||
$this->statuses = $model->getStatuses();
|
|
||||||
$this->priorities = $model->getPriorities();
|
|
||||||
|
|
||||||
$this->addToolbar();
|
|
||||||
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function addToolbar(): void
|
|
||||||
{
|
|
||||||
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_TICKET_SETTINGS'), 'cog');
|
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<html><body bgcolor="#FFFFFF"></body></html>
|
|
||||||
@@ -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;
|
$checkedOut = $this->checkedOutItems;
|
||||||
$wafBlocks = $this->wafBlocks;
|
$wafBlocks = $this->wafBlocks;
|
||||||
$token = Session::getFormToken();
|
$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
|
// Group plugins by category
|
||||||
$grouped = [];
|
$grouped = [];
|
||||||
@@ -34,75 +37,50 @@ foreach ($plugins as $plugin)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
$categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||||
|
$actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_actionlogs');
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div id="mokosuiteclient-dashboard">
|
<div id="mokosuiteclient-dashboard">
|
||||||
|
<?php if (!$actionLogsEnabled): ?>
|
||||||
|
<div class="alert alert-danger d-flex align-items-center gap-2 mb-4">
|
||||||
|
<span class="icon-exclamation-triangle" style="font-size:1.25rem"></span>
|
||||||
|
<div>
|
||||||
|
<strong>Action Logs Required</strong> — MokoSuite requires Joomla's Action Logs component to be enabled for login tracking and audit compliance.
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_plugins&filter[search]=actionlog'); ?>" class="alert-link ms-1">Enable Action Log Plugins</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<!-- Site Info Bar -->
|
<!-- Site Info Bar -->
|
||||||
<div class="mokosuiteclient-info-bar card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-body d-flex flex-wrap align-items-center gap-4">
|
<div class="card-body d-flex flex-wrap align-items-center gap-2" style="padding:0.75rem 1.25rem;font-size:0.85rem;">
|
||||||
<div class="mokosuiteclient-info-item">
|
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.1rem;color:#1a2744"></span>
|
||||||
<span class="mokosuiteclient-info-label"><?php echo Text::_('COM_MOKOSUITECLIENT_SITE'); ?></span>
|
<span class="fw-bold"><?php echo $this->escape($siteInfo->sitename); ?></span>
|
||||||
<span class="mokosuiteclient-info-value fw-bold"><?php echo $this->escape($siteInfo->sitename); ?></span>
|
<span class="badge bg-primary">MokoSuite <?php echo $this->escape($siteInfo->mokosuiteclient_version); ?></span>
|
||||||
</div>
|
|
||||||
<div class="mokosuiteclient-info-item">
|
|
||||||
<span class="mokosuiteclient-info-label">MokoSuiteClient</span>
|
|
||||||
<span class="mokosuiteclient-info-value"><span class="badge bg-primary"><?php echo $this->escape($siteInfo->mokosuiteclient_version); ?></span></span>
|
|
||||||
</div>
|
|
||||||
<?php if (!empty($this->supportPin)): ?>
|
<?php if (!empty($this->supportPin)): ?>
|
||||||
<div class="mokosuiteclient-info-item">
|
<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 class="mokosuiteclient-info-label">Support PIN</span>
|
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-1" id="mokosuiteclient-btn-heartbeat-pin"
|
||||||
<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. Ask your provider for this code to verify identity."><span class="icon-key small me-1" aria-hidden="true"></span><?php echo $this->escape($this->supportPin); ?></span></span>
|
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.sendHeartbeat&format=json'); ?>"
|
||||||
</div>
|
data-token="<?php echo $token; ?>"
|
||||||
|
title="Send heartbeat with PIN to MokoSuiteHQ">
|
||||||
|
<span class="icon-upload" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="mokosuiteclient-info-item">
|
<span class="badge bg-secondary">Joomla <?php echo $this->escape($siteInfo->joomla_version); ?></span>
|
||||||
<span class="mokosuiteclient-info-label">Joomla</span>
|
<span class="badge bg-secondary">PHP <?php echo $this->escape($siteInfo->php_version); ?></span>
|
||||||
<span class="mokosuiteclient-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->joomla_version); ?></span></span>
|
<span class="badge bg-secondary"><?php echo $this->escape($siteInfo->db_type); ?></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>
|
|
||||||
<?php if ($siteInfo->debug): ?>
|
<?php if ($siteInfo->debug): ?>
|
||||||
<span class="badge bg-warning text-dark"><?php echo Text::_('COM_MOKOSUITECLIENT_DEBUG_ON'); ?></span>
|
<span class="badge bg-warning text-dark">Debug ON</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($siteInfo->offline): ?>
|
<?php if ($siteInfo->offline): ?>
|
||||||
<span class="badge bg-danger"><?php echo Text::_('COM_MOKOSUITECLIENT_OFFLINE'); ?></span>
|
<span class="badge bg-danger">Offline</span>
|
||||||
<?php endif; ?>
|
<?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>
|
<span class="icon-globe" aria-hidden="true"></span>
|
||||||
<code><?php echo $this->escape($_SERVER['REMOTE_ADDR'] ?? ''); ?></code>
|
<code><?php echo $this->escape($_SERVER['REMOTE_ADDR'] ?? ''); ?></code>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!empty($mokoExts)): ?>
|
|
||||||
<!-- Moko Component & Module Versions -->
|
|
||||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
|
||||||
<?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',
|
|
||||||
];
|
|
||||||
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="d-flex align-items-center gap-2 px-3 py-2 rounded border bg-white" style="font-size:0.85rem;">
|
|
||||||
<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): ?>
|
<?php if ($adminToolsAvail || $atsAvail): ?>
|
||||||
<!-- Akeeba Import Banner -->
|
<!-- Akeeba Import Banner -->
|
||||||
@@ -203,7 +181,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="mokosuiteclient-plugin-grid row g-3 mb-4">
|
<div class="mokosuiteclient-plugin-grid row g-3 mb-4">
|
||||||
<?php foreach ($catPlugins as $plugin): ?>
|
<?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'; ?>"
|
<div class="card mokosuiteclient-plugin-card h-100 <?php echo $plugin->enabled ? '' : 'mokosuiteclient-plugin-disabled'; ?>"
|
||||||
data-extension-id="<?php echo $plugin->extension_id; ?>">
|
data-extension-id="<?php echo $plugin->extension_id; ?>">
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
@@ -220,11 +198,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
|||||||
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
|
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
|
||||||
<?php if ($plugin->protected): ?>
|
<?php if ($plugin->protected): ?>
|
||||||
<span class="badge bg-dark"><?php echo Text::_('COM_MOKOSUITECLIENT_PROTECTED'); ?></span>
|
<span class="badge bg-dark"><?php echo Text::_('COM_MOKOSUITECLIENT_PROTECTED'); ?></span>
|
||||||
<?php elseif ($plugin->configure_only): ?>
|
<?php elseif ($plugin->extension_id): ?>
|
||||||
<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: ?>
|
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input type="checkbox" class="form-check-input mokosuiteclient-toggle" role="switch"
|
<input type="checkbox" class="form-check-input mokosuiteclient-toggle" role="switch"
|
||||||
id="toggle-<?php echo $plugin->extension_id; ?>"
|
id="toggle-<?php echo $plugin->extension_id; ?>"
|
||||||
@@ -237,7 +211,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?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">
|
<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'); ?>
|
<span class="icon-cog" aria-hidden="true"></span> <?php echo Text::_('COM_MOKOSUITECLIENT_CONFIGURE'); ?>
|
||||||
</a>
|
</a>
|
||||||
@@ -254,6 +228,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
|||||||
<!-- Right: Charts & Information (4 cols) -->
|
<!-- 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;">
|
<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 -->
|
<!-- WAF Activity Chart -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -263,6 +238,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
|||||||
<canvas id="mokosuiteclient-chart-waf" height="140"></canvas>
|
<canvas id="mokosuiteclient-chart-waf" height="140"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Login Activity Chart -->
|
<!-- Login Activity Chart -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
@@ -333,6 +309,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canWafLog): ?>
|
||||||
<!-- WAF Blocks -->
|
<!-- WAF Blocks -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
@@ -360,6 +337,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Recent Logins -->
|
<!-- Recent Logins -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
@@ -369,12 +347,27 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
|||||||
<?php if (!empty($recentLogins)): ?>
|
<?php if (!empty($recentLogins)): ?>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-striped mb-0">
|
<table class="table table-sm table-striped mb-0">
|
||||||
<thead><tr><th>User</th><th>IP</th><th>Time</th></tr></thead>
|
<thead><tr><th>User</th><th>App</th><th>IP</th><th>Time</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($recentLogins as $login): ?>
|
<?php foreach ($recentLogins as $login):
|
||||||
|
$msgData = json_decode($login->message ?? '{}');
|
||||||
|
$appKey = $msgData->app ?? '';
|
||||||
|
if (stripos($appKey, 'ADMINISTRATOR') !== false) {
|
||||||
|
$appLabel = 'Admin';
|
||||||
|
$appBadge = 'bg-dark';
|
||||||
|
} elseif (stripos($appKey, 'SITE') !== false) {
|
||||||
|
$appLabel = 'Site';
|
||||||
|
$appBadge = 'bg-info text-dark';
|
||||||
|
} else {
|
||||||
|
$appLabel = 'Unknown';
|
||||||
|
$appBadge = 'bg-secondary';
|
||||||
|
}
|
||||||
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-muted"><?php echo $this->escape($login->username ?? ''); ?></td>
|
<td class="text-muted"><?php echo $this->escape($login->username ?? ''); ?></td>
|
||||||
<td class="text-muted"><code><?php echo $this->escape($login->ip_address ?? ''); ?></code></td>
|
<td><span class="badge <?php echo $appBadge; ?>" style="font-size:0.7rem;"><?php echo $appLabel; ?></span></td>
|
||||||
|
<?php $ip = $login->ip_address ?? ''; ?>
|
||||||
|
<td class="text-muted"><?php if ($ip && $ip !== 'COM_ACTIONLOGS_DISABLED'): ?><code><?php echo $this->escape($ip); ?></code><?php else: ?><span class="text-muted fst-italic">IP logging off</span><?php endif; ?></td>
|
||||||
<td class="text-muted"><?php echo HTMLHelper::_('date', $login->log_date, Text::_('DATE_FORMAT_LC4')); ?></td>
|
<td class="text-muted"><?php echo HTMLHelper::_('date', $login->log_date, Text::_('DATE_FORMAT_LC4')); ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
@@ -1,365 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\HTML\HTMLHelper;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
use Joomla\CMS\Session\Session;
|
|
||||||
|
|
||||||
$t = $this->ticket;
|
|
||||||
$canned = $this->cannedResponses;
|
|
||||||
$token = Session::getFormToken();
|
|
||||||
$attachments = $this->attachments;
|
|
||||||
$downloadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.downloadAttachment');
|
|
||||||
$uploadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.uploadAttachment&format=json');
|
|
||||||
$deleteAttUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteAttachment&format=json');
|
|
||||||
|
|
||||||
// Group attachments by reply_id (null = ticket-level)
|
|
||||||
$attByReply = [];
|
|
||||||
foreach ($attachments as $att) {
|
|
||||||
$key = $att->reply_id ?? 0;
|
|
||||||
$attByReply[$key][] = $att;
|
|
||||||
}
|
|
||||||
|
|
||||||
$statuses = $this->statuses ?? [];
|
|
||||||
$priorities = $this->priorities ?? [];
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div id="mokosuiteclient-ticket" class="row">
|
|
||||||
<!-- Left: conversation thread -->
|
|
||||||
<div class="col-12 col-xl-8">
|
|
||||||
<!-- Original ticket -->
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<strong><?php echo $this->escape($t->created_by_name); ?></strong>
|
|
||||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, Text::_('DATE_FORMAT_LC2')); ?></small>
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-dark">Original</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<?php echo nl2br($this->escape($t->body)); ?>
|
|
||||||
<?php if (!empty($attByReply[0])): ?>
|
|
||||||
<hr>
|
|
||||||
<div class="small">
|
|
||||||
<strong>Attachments:</strong>
|
|
||||||
<?php foreach ($attByReply[0] as $att): ?>
|
|
||||||
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
|
|
||||||
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
|
|
||||||
<span class="text-muted">(<?php echo \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
|
|
||||||
</a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Replies -->
|
|
||||||
<?php foreach ($t->replies as $reply): ?>
|
|
||||||
<div class="card mb-3 <?php echo $reply->is_internal ? 'border-warning' : ''; ?>">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<strong><?php echo $this->escape($reply->user_name ?? 'System'); ?></strong>
|
|
||||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, Text::_('DATE_FORMAT_LC2')); ?></small>
|
|
||||||
</div>
|
|
||||||
<?php if ($reply->is_internal): ?>
|
|
||||||
<span class="badge bg-warning text-dark">Internal Note</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<?php echo nl2br($this->escape($reply->body)); ?>
|
|
||||||
<?php if (!empty($attByReply[$reply->id])): ?>
|
|
||||||
<hr>
|
|
||||||
<div class="small">
|
|
||||||
<strong>Attachments:</strong>
|
|
||||||
<?php foreach ($attByReply[$reply->id] as $att): ?>
|
|
||||||
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
|
|
||||||
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
|
|
||||||
<span class="text-muted">(<?php echo \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
|
|
||||||
</a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
<!-- Reply form -->
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><strong>Reply</strong></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<?php if (!empty($canned)): ?>
|
|
||||||
<div class="mb-2">
|
|
||||||
<select class="form-select form-select-sm" id="canned-select">
|
|
||||||
<option value="">Insert canned response...</option>
|
|
||||||
<?php foreach ($canned as $c): ?>
|
|
||||||
<option value="<?php echo $this->escape($c->body); ?>"><?php echo $this->escape($c->title); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
|
|
||||||
<div class="mb-2">
|
|
||||||
<input type="file" id="reply-attachments" class="form-control form-control-sm" multiple
|
|
||||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.zip">
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="button" class="btn btn-primary" id="btn-reply"
|
|
||||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.addTicketReply&format=json'); ?>"
|
|
||||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>">
|
|
||||||
<span class="icon-reply"></span> Send Reply
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-warning" id="btn-internal"
|
|
||||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.addTicketReply&format=json'); ?>"
|
|
||||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" data-internal="1">
|
|
||||||
<span class="icon-eye-slash"></span> Internal Note
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: ticket metadata -->
|
|
||||||
<div class="col-12 col-xl-4">
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><strong>Details</strong></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<tr><td class="text-muted">Status</td><td><span class="badge <?php echo $this->escape($t->status_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->status_title ?? $t->status); ?></span></td></tr>
|
|
||||||
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $this->escape($t->priority_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->priority_title ?? $t->priority); ?></span></td></tr>
|
|
||||||
<tr><td class="text-muted">Category</td><td><?php echo $this->escape($t->category_title ?? '—'); ?></td></tr>
|
|
||||||
<tr><td class="text-muted">Created By</td><td><?php echo $this->escape($t->created_by_name); ?><br><small><?php echo $this->escape($t->created_by_email ?? ''); ?></small></td></tr>
|
|
||||||
<tr><td class="text-muted">Assigned To</td><td><?php
|
|
||||||
if (!empty($t->assignees)) {
|
|
||||||
foreach ($t->assignees as $a) {
|
|
||||||
$icon = $a->assignee_type === 'group' ? '<span class="icon-users"></span> ' : '<span class="icon-user"></span> ';
|
|
||||||
echo '<div>' . $icon . $this->escape($a->name) . '</div>';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo '<em>Unassigned</em>';
|
|
||||||
}
|
|
||||||
?></td></tr>
|
|
||||||
<?php if ($t->contact_id): ?>
|
|
||||||
<tr><td class="text-muted">Contact</td><td>
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_contact&task=contact.edit&id=' . (int) $t->contact_id); ?>">
|
|
||||||
<?php echo $this->escape($t->contact_name ?? 'Contact #' . $t->contact_id); ?>
|
|
||||||
</a>
|
|
||||||
<?php if (!empty($t->contact_email)): ?><br><small><?php echo $this->escape($t->contact_email); ?></small><?php endif; ?>
|
|
||||||
<?php if (!empty($t->contact_phone)): ?><br><small><?php echo $this->escape($t->contact_phone); ?></small><?php endif; ?>
|
|
||||||
</td></tr>
|
|
||||||
<?php endif; ?>
|
|
||||||
<tr><td class="text-muted">Created</td><td><?php echo HTMLHelper::_('date', $t->created, Text::_('DATE_FORMAT_LC2')); ?></td></tr>
|
|
||||||
<?php if ($t->resolved): ?><tr><td class="text-muted">Resolved</td><td><?php echo HTMLHelper::_('date', $t->resolved, Text::_('DATE_FORMAT_LC2')); ?></td></tr><?php endif; ?>
|
|
||||||
<?php if ($t->closed): ?><tr><td class="text-muted">Closed</td><td><?php echo HTMLHelper::_('date', $t->closed, Text::_('DATE_FORMAT_LC2')); ?></td></tr><?php endif; ?>
|
|
||||||
<tr><td class="text-muted">Replies</td><td><?php echo $t->reply_count; ?></td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SLA -->
|
|
||||||
<?php if ($t->sla_response_due || $t->sla_resolution_due): ?>
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><strong>SLA</strong></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<?php if ($t->sla_response_due): ?>
|
|
||||||
<div class="mb-2">
|
|
||||||
<small class="text-muted">Response Due</small><br>
|
|
||||||
<?php
|
|
||||||
$responseOverdue = !$t->sla_responded && strtotime($t->sla_response_due) < time();
|
|
||||||
?>
|
|
||||||
<span class="<?php echo $t->sla_responded ? 'text-success' : ($responseOverdue ? 'text-danger fw-bold' : ''); ?>">
|
|
||||||
<?php echo $t->sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, Text::_('DATE_FORMAT_LC4')); ?>
|
|
||||||
<?php echo $responseOverdue ? ' OVERDUE' : ''; ?>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($t->sla_resolution_due): ?>
|
|
||||||
<div>
|
|
||||||
<small class="text-muted">Resolution Due</small><br>
|
|
||||||
<?php
|
|
||||||
$resolutionOverdue = !!empty($t->status_is_closed) && strtotime($t->sla_resolution_due) < time();
|
|
||||||
?>
|
|
||||||
<span class="<?php echo !empty($t->status_is_closed) ? 'text-success' : ($resolutionOverdue ? 'text-danger fw-bold' : ''); ?>">
|
|
||||||
<?php echo !empty($t->status_is_closed) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, Text::_('DATE_FORMAT_LC4')); ?>
|
|
||||||
<?php echo $resolutionOverdue ? ' OVERDUE' : ''; ?>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Satisfaction Rating -->
|
|
||||||
<?php
|
|
||||||
$isClosed = in_array($t->status, ['resolved', 'closed'], true);
|
|
||||||
$hasRating = !empty($t->satisfaction_rating);
|
|
||||||
?>
|
|
||||||
<?php if ($hasRating): ?>
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><strong>Satisfaction</strong></div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<div class="mb-1">
|
|
||||||
<?php for ($s = 1; $s <= 5; $s++): ?>
|
|
||||||
<span style="font-size:1.5rem;color:<?php echo $s <= $t->satisfaction_rating ? '#f5a623' : '#dee2e6'; ?>;">★</span>
|
|
||||||
<?php endfor; ?>
|
|
||||||
</div>
|
|
||||||
<div class="text-muted small"><?php echo $t->satisfaction_rating; ?>/5</div>
|
|
||||||
<?php if (!empty($t->satisfaction_feedback)): ?>
|
|
||||||
<p class="small mt-2 mb-0"><?php echo $this->escape($t->satisfaction_feedback); ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php elseif ($isClosed): ?>
|
|
||||||
<div class="card mb-3" id="rating-card">
|
|
||||||
<div class="card-header"><strong>Rate this Support</strong></div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<div class="mb-2" id="star-rating">
|
|
||||||
<?php for ($s = 1; $s <= 5; $s++): ?>
|
|
||||||
<span class="star-btn" data-value="<?php echo $s; ?>" style="font-size:2rem;cursor:pointer;color:#dee2e6;">★</span>
|
|
||||||
<?php endfor; ?>
|
|
||||||
</div>
|
|
||||||
<textarea id="rating-feedback" class="form-control form-control-sm mb-2" rows="2" placeholder="Optional feedback..."></textarea>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="btn-rate"
|
|
||||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.rateTicket&format=json'); ?>"
|
|
||||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" disabled>
|
|
||||||
Submit Rating
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Status actions -->
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><strong>Actions</strong></div>
|
|
||||||
<div class="card-body d-grid gap-2">
|
|
||||||
<?php foreach ($statuses as $s): ?>
|
|
||||||
<?php if ((int) $s->id !== (int) $t->status_id): ?>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-<?php echo $s->is_closed ? 'danger' : 'secondary'; ?> btn-status"
|
|
||||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.updateTicketStatus&format=json'); ?>"
|
|
||||||
data-ticket="<?php echo $t->id; ?>" data-status="<?php echo $s->id; ?>" data-token="<?php echo $token; ?>">
|
|
||||||
<?php echo $this->escape($s->title); ?>
|
|
||||||
</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom Fields -->
|
|
||||||
<?php if (!empty($this->customFields)): ?>
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><strong>Custom Fields</strong></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<?php foreach ($this->customFields as $field): ?>
|
|
||||||
<tr>
|
|
||||||
<td class="text-muted"><?php echo $this->escape($field->title); ?></td>
|
|
||||||
<td><?php echo $this->escape($this->fieldValues[(int) $field->id] ?? '—'); ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Canned response insert
|
|
||||||
var cannedSel = document.getElementById('canned-select');
|
|
||||||
if (cannedSel) {
|
|
||||||
cannedSel.addEventListener('change', function() {
|
|
||||||
if (this.value) { document.getElementById('reply-body').value = this.value; this.selectedIndex = 0; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reply buttons (with attachment upload)
|
|
||||||
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
var body = document.getElementById('reply-body').value.trim();
|
|
||||||
var fileInput = document.getElementById('reply-attachments');
|
|
||||||
if (!body && (!fileInput || !fileInput.files.length)) return;
|
|
||||||
var el = this;
|
|
||||||
el.disabled = true;
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('ticket_id', el.dataset.ticket);
|
|
||||||
fd.append('body', body || '(attachment)');
|
|
||||||
fd.append('is_internal', el.dataset.internal || '0');
|
|
||||||
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({error:[d.message]}); el.disabled = false; return; }
|
|
||||||
// Upload attachments if any
|
|
||||||
if (fileInput && fileInput.files.length > 0) {
|
|
||||||
var afd = new FormData();
|
|
||||||
afd.append('ticket_id', el.dataset.ticket);
|
|
||||||
if (d.reply_id) afd.append('reply_id', d.reply_id);
|
|
||||||
for (var i = 0; i < fileInput.files.length; i++) {
|
|
||||||
afd.append('attachments[' + i + ']', fileInput.files[i]);
|
|
||||||
}
|
|
||||||
afd.append(el.dataset.token, '1');
|
|
||||||
fetch('<?php echo $uploadUrl; ?>', {method:'POST', body:afd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
|
||||||
.then(function(){ location.reload(); });
|
|
||||||
} else {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(){ el.disabled = false; });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Status buttons
|
|
||||||
document.querySelectorAll('.btn-status').forEach(function(btn) {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
var el = this;
|
|
||||||
el.disabled = true;
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('ticket_id', el.dataset.ticket);
|
|
||||||
fd.append('status', el.dataset.status);
|
|
||||||
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) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
|
||||||
.finally(function(){ el.disabled = false; });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Star rating
|
|
||||||
var selectedRating = 0;
|
|
||||||
document.querySelectorAll('.star-btn').forEach(function(star) {
|
|
||||||
star.addEventListener('mouseenter', function() {
|
|
||||||
var val = parseInt(this.dataset.value);
|
|
||||||
document.querySelectorAll('.star-btn').forEach(function(s) {
|
|
||||||
s.style.color = parseInt(s.dataset.value) <= val ? '#f5a623' : '#dee2e6';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
star.addEventListener('mouseleave', function() {
|
|
||||||
document.querySelectorAll('.star-btn').forEach(function(s) {
|
|
||||||
s.style.color = parseInt(s.dataset.value) <= selectedRating ? '#f5a623' : '#dee2e6';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
star.addEventListener('click', function() {
|
|
||||||
selectedRating = parseInt(this.dataset.value);
|
|
||||||
document.getElementById('btn-rate').disabled = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
var rateBtn = document.getElementById('btn-rate');
|
|
||||||
if (rateBtn) {
|
|
||||||
rateBtn.addEventListener('click', function() {
|
|
||||||
if (!selectedRating) return;
|
|
||||||
var el = this;
|
|
||||||
el.disabled = true;
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('ticket_id', el.dataset.ticket);
|
|
||||||
fd.append('rating', selectedRating);
|
|
||||||
fd.append('feedback', document.getElementById('rating-feedback').value);
|
|
||||||
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) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
|
||||||
.finally(function(){ el.disabled = false; });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\HTML\HTMLHelper;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
use Joomla\CMS\Session\Session;
|
|
||||||
|
|
||||||
$tickets = $this->tickets;
|
|
||||||
$categories = $this->categories;
|
|
||||||
$statuses = $this->statuses;
|
|
||||||
$priorities = $this->priorities;
|
|
||||||
$counts = $this->statusCounts;
|
|
||||||
$overdue = $this->overdue;
|
|
||||||
$atsAvailable = $this->atsAvailable;
|
|
||||||
$token = Session::getFormToken();
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div id="mokosuiteclient-tickets">
|
|
||||||
<!-- Status summary cards -->
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<?php foreach ($counts as $sc): ?>
|
|
||||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo (int) $sc->cnt; ?></span><small class="text-muted"><?php echo $this->escape($sc->title); ?></small></div></div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php if (\count($overdue) > 0): ?>
|
|
||||||
<div class="col"><div class="card text-center p-2 border-danger"><span class="fw-bold fs-4 text-danger"><?php echo \count($overdue); ?></span><small class="text-danger">SLA Overdue</small></div></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- New ticket + filters -->
|
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTicketModal">
|
|
||||||
<span class="icon-plus"></span> New Ticket
|
|
||||||
</button>
|
|
||||||
<?php if ($atsAvailable): ?>
|
|
||||||
<button type="button" class="btn btn-outline-info" id="btn-import-ats"
|
|
||||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.importAts&format=json'); ?>"
|
|
||||||
data-token="<?php echo $token; ?>"
|
|
||||||
data-tickets="<?php echo $atsAvailable->tickets; ?>"
|
|
||||||
data-posts="<?php echo $atsAvailable->posts; ?>">
|
|
||||||
<span class="icon-upload"></span> Import from Akeeba (<?php echo $atsAvailable->tickets; ?> tickets, <?php echo $atsAvailable->posts; ?> posts)
|
|
||||||
</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<form method="get" class="d-flex gap-2">
|
|
||||||
<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 ($statuses as $s): ?>
|
|
||||||
<option value="<?php echo $s->id; ?>" <?php echo Factory::getApplication()->getInput()->getInt('filter_status') === (int) $s->id ? 'selected' : ''; ?>><?php echo $this->escape($s->title); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<select name="filter_priority" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
|
||||||
<option value="">All Priorities</option>
|
|
||||||
<?php foreach ($priorities as $p): ?>
|
|
||||||
<option value="<?php echo $p->id; ?>" <?php echo Factory::getApplication()->getInput()->getInt('filter_priority') === (int) $p->id ? 'selected' : ''; ?>><?php echo $this->escape($p->title); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ticket table -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>Subject</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Priority</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Contact</th>
|
|
||||||
<th>Created By</th>
|
|
||||||
<th>Assigned To</th>
|
|
||||||
<th>Created</th>
|
|
||||||
<th>SLA</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php if (empty($tickets)): ?>
|
|
||||||
<tr><td colspan="10" class="text-center text-muted py-4">No tickets found.</td></tr>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($tickets as $t): ?>
|
|
||||||
<?php
|
|
||||||
$slaClass = '';
|
|
||||||
$now = time();
|
|
||||||
if ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger';
|
|
||||||
elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && empty($t->status_is_closed)) $slaClass = 'table-danger';
|
|
||||||
elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning';
|
|
||||||
?>
|
|
||||||
<tr class="<?php echo $slaClass; ?>">
|
|
||||||
<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 $this->escape(mb_substr($t->subject, 0, 60)); ?></a></td>
|
|
||||||
<td><span class="badge <?php echo $this->escape($t->status_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->status_title ?? $t->status); ?></span></td>
|
|
||||||
<td><span class="badge <?php echo $this->escape($t->priority_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->priority_title ?? $t->priority); ?></span></td>
|
|
||||||
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
|
|
||||||
<td><?php echo $t->contact_name ? '<a href="' . Route::_('index.php?option=com_contact&task=contact.edit&id=' . (int) $t->contact_id) . '">' . $this->escape($t->contact_name) . '</a>' : '—'; ?></td>
|
|
||||||
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
|
|
||||||
<td><?php
|
|
||||||
if (!empty($t->assignees)) {
|
|
||||||
$names = [];
|
|
||||||
foreach ($t->assignees as $a) {
|
|
||||||
$icon = $a->assignee_type === 'group' ? '<span class="icon-users"></span> ' : '';
|
|
||||||
$names[] = $icon . $this->escape($a->name);
|
|
||||||
}
|
|
||||||
echo implode(', ', $names);
|
|
||||||
} else {
|
|
||||||
echo '<em>Unassigned</em>';
|
|
||||||
}
|
|
||||||
?></td>
|
|
||||||
<td class="small"><?php echo HTMLHelper::_('date', $t->created, Text::_('DATE_FORMAT_LC4')); ?></td>
|
|
||||||
<td class="small">
|
|
||||||
<?php if ($t->sla_response_due && !$t->sla_responded): ?>
|
|
||||||
<span title="Response due"><?php echo HTMLHelper::_('date', $t->sla_response_due, Text::_('DATE_FORMAT_LC4')); ?></span>
|
|
||||||
<?php elseif ($t->sla_resolution_due): ?>
|
|
||||||
<span title="Resolution due"><?php echo HTMLHelper::_('date', $t->sla_resolution_due, Text::_('DATE_FORMAT_LC4')); ?></span>
|
|
||||||
<?php else: ?>—<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- New Ticket Modal -->
|
|
||||||
<div class="modal fade" id="newTicketModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header"><h5 class="modal-title">New Ticket</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<!-- KB Search step -->
|
|
||||||
<div id="modal-kb-step">
|
|
||||||
<label class="form-label fw-bold">What's the issue?</label>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<input type="text" id="modal-kb-search" class="form-control" placeholder="Describe your issue to search for existing answers...">
|
|
||||||
<button type="button" class="btn btn-outline-primary" id="modal-kb-btn"><span class="icon-search"></span></button>
|
|
||||||
</div>
|
|
||||||
<div id="modal-kb-results" class="list-group mb-3 d-none"></div>
|
|
||||||
<button type="button" class="btn btn-primary" id="modal-show-form">
|
|
||||||
<span class="icon-plus"></span> Create Ticket
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ticket form step (hidden initially) -->
|
|
||||||
<form id="modal-ticket-form" class="d-none" method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.createTicket&format=json'); ?>">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Subject</label>
|
|
||||||
<input type="text" name="subject" id="modal-subject" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Category</label>
|
|
||||||
<select name="category_id" class="form-select">
|
|
||||||
<option value="">— Select —</option>
|
|
||||||
<?php foreach ($categories as $cat): ?>
|
|
||||||
<option value="<?php echo $cat->id; ?>"><?php echo $this->escape($cat->title); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Priority</label>
|
|
||||||
<select name="priority_id" class="form-select">
|
|
||||||
<?php foreach ($priorities as $p): ?>
|
|
||||||
<option value="<?php echo $p->id; ?>" <?php echo $p->is_default ? 'selected' : ''; ?>><?php echo $this->escape($p->title); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Contact</label>
|
|
||||||
<select name="contact_id" class="form-select">
|
|
||||||
<option value="">— None —</option>
|
|
||||||
<?php foreach ($this->contacts as $contact): ?>
|
|
||||||
<option value="<?php echo $contact->id; ?>"><?php echo $this->escape($contact->name); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Assign Users</label>
|
|
||||||
<select name="assign_users[]" class="form-select" multiple size="4">
|
|
||||||
<?php foreach ($this->backendUsers as $u): ?>
|
|
||||||
<option value="<?php echo $u->id; ?>"><?php echo $this->escape($u->name); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<small class="text-muted">Hold Ctrl/Cmd to select multiple</small>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Assign Groups</label>
|
|
||||||
<select name="assign_groups[]" class="form-select" multiple size="4">
|
|
||||||
<?php foreach ($this->userGroups as $g): ?>
|
|
||||||
<option value="<?php echo $g->id; ?>"><?php echo $this->escape($g->title); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<small class="text-muted">Hold Ctrl/Cmd to select multiple</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<textarea name="body" class="form-control" rows="6" required></textarea>
|
|
||||||
</div>
|
|
||||||
<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-plus"></span> Create Ticket</button>
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Modal KB search
|
|
||||||
var modalSearch = document.getElementById('modal-kb-search');
|
|
||||||
var modalSearchBtn = document.getElementById('modal-kb-btn');
|
|
||||||
var modalResults = document.getElementById('modal-kb-results');
|
|
||||||
var modalShowForm = document.getElementById('modal-show-form');
|
|
||||||
var modalKbStep = document.getElementById('modal-kb-step');
|
|
||||||
var modalForm = document.getElementById('modal-ticket-form');
|
|
||||||
var modalSubject = document.getElementById('modal-subject');
|
|
||||||
|
|
||||||
function modalDoSearch() {
|
|
||||||
var q = modalSearch.value.trim();
|
|
||||||
if (q.length < 3) return;
|
|
||||||
fetch('<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.searchKb&format=json'); ?>&q=' + encodeURIComponent(q), {
|
|
||||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
|
||||||
}).then(function(r){return r.json()}).then(function(d) {
|
|
||||||
modalResults.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);
|
|
||||||
}
|
|
||||||
modalResults.appendChild(a);
|
|
||||||
});
|
|
||||||
modalResults.classList.remove('d-none');
|
|
||||||
} else {
|
|
||||||
modalResults.classList.add('d-none');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (modalSearchBtn) modalSearchBtn.addEventListener('click', modalDoSearch);
|
|
||||||
if (modalSearch) modalSearch.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); modalDoSearch(); } });
|
|
||||||
|
|
||||||
// Show ticket form
|
|
||||||
if (modalShowForm) {
|
|
||||||
modalShowForm.addEventListener('click', function() {
|
|
||||||
modalKbStep.classList.add('d-none');
|
|
||||||
modalForm.classList.remove('d-none');
|
|
||||||
if (modalSearch.value && !modalSubject.value) modalSubject.value = modalSearch.value;
|
|
||||||
modalSubject.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit ticket from modal
|
|
||||||
if (modalForm) {
|
|
||||||
modalForm.addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var form = this;
|
|
||||||
var fd = new FormData(form);
|
|
||||||
fetch(form.action, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
|
||||||
.then(function(r){return r.json()})
|
|
||||||
.then(function(d){
|
|
||||||
if (d.success) { location.href = 'index.php?option=com_mokosuiteclient&view=ticket&id=' + d.id; }
|
|
||||||
else { Joomla.renderMessages({error:[d.message]}); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset modal on close
|
|
||||||
document.getElementById('newTicketModal').addEventListener('hidden.bs.modal', function() {
|
|
||||||
modalKbStep.classList.remove('d-none');
|
|
||||||
modalForm.classList.add('d-none');
|
|
||||||
modalResults.classList.add('d-none');
|
|
||||||
modalSearch.value = '';
|
|
||||||
modalForm.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ATS Import
|
|
||||||
var atsBtn = document.getElementById('btn-import-ats');
|
|
||||||
if (atsBtn) {
|
|
||||||
atsBtn.addEventListener('click', function() {
|
|
||||||
var el = this;
|
|
||||||
if (!confirm('Import ' + el.dataset.tickets + ' tickets and ' + el.dataset.posts + ' posts from Akeeba Ticket System? Duplicates will be skipped.')) return;
|
|
||||||
el.disabled = true;
|
|
||||||
el.textContent = ' Importing...';
|
|
||||||
var fd = new FormData();
|
|
||||||
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:[d.message]}); location.reload(); }
|
|
||||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = 'Import Failed - Retry'; }
|
|
||||||
})
|
|
||||||
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
*
|
|
||||||
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
|
||||||
*
|
|
||||||
* @package MokoSuiteClient
|
|
||||||
* @subpackage Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\HTML\HTMLHelper;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
use Joomla\CMS\Session\Session;
|
|
||||||
|
|
||||||
$token = Session::getFormToken();
|
|
||||||
|
|
||||||
$colorOptions = [
|
|
||||||
'bg-primary', 'bg-secondary', 'bg-success', 'bg-danger',
|
|
||||||
'bg-warning text-dark', 'bg-info text-dark', 'bg-dark', 'bg-light text-dark',
|
|
||||||
];
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<!-- Statuses -->
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<strong><span class="fa-solid fa-circle-dot"></span> Ticket Statuses</strong>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<table class="table table-striped mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Title</th>
|
|
||||||
<th class="w-10 text-center">Color</th>
|
|
||||||
<th class="w-10 text-center">Default</th>
|
|
||||||
<th class="w-10 text-center">Closed?</th>
|
|
||||||
<th class="w-10 text-center">Order</th>
|
|
||||||
<th class="w-10 text-center">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($this->statuses as $s): ?>
|
|
||||||
<tr>
|
|
||||||
<td><?php echo $this->escape($s->title); ?> <small class="text-muted">(<?php echo $this->escape($s->alias); ?>)</small></td>
|
|
||||||
<td class="text-center"><span class="badge <?php echo $this->escape($s->color); ?>"> </span></td>
|
|
||||||
<td class="text-center"><?php echo $s->is_default ? '<span class="badge bg-success">Yes</span>' : ''; ?></td>
|
|
||||||
<td class="text-center"><?php echo $s->is_closed ? '<span class="badge bg-dark">Closed</span>' : ''; ?></td>
|
|
||||||
<td class="text-center"><?php echo (int) $s->ordering; ?></td>
|
|
||||||
<td class="text-center">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="editStatus(<?php echo htmlspecialchars(json_encode($s)); ?>)">
|
|
||||||
<span class="icon-pencil"></span>
|
|
||||||
</button>
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.deleteStatus&id=' . $s->id . '&' . $token . '=1'); ?>"
|
|
||||||
class="btn btn-sm btn-outline-danger"
|
|
||||||
onclick="return confirm('Delete this status?')">
|
|
||||||
<span class="icon-trash"></span>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<form method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.saveStatus'); ?>" id="statusForm" class="row g-2 align-items-end">
|
|
||||||
<input type="hidden" name="id" id="status-id" value="0">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label small">Title</label>
|
|
||||||
<input type="text" name="title" id="status-title" class="form-control form-control-sm" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small">Alias</label>
|
|
||||||
<input type="text" name="alias" id="status-alias" class="form-control form-control-sm">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small">Color</label>
|
|
||||||
<select name="color" id="status-color" class="form-select form-select-sm">
|
|
||||||
<?php foreach ($colorOptions as $c): ?>
|
|
||||||
<option value="<?php echo $c; ?>"><?php echo $c; ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1">
|
|
||||||
<label class="form-label small">Order</label>
|
|
||||||
<input type="number" name="ordering" id="status-ordering" class="form-control form-control-sm" value="0">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1 text-center">
|
|
||||||
<label class="form-label small">Default</label>
|
|
||||||
<input type="checkbox" name="is_default" id="status-default" value="1" class="form-check-input">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1 text-center">
|
|
||||||
<label class="form-label small">Closed</label>
|
|
||||||
<input type="checkbox" name="is_closed" id="status-closed" value="1" class="form-check-input">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
|
||||||
<button type="submit" class="btn btn-sm btn-primary w-100" id="status-btn">Add</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Priorities -->
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<strong><span class="fa-solid fa-flag"></span> Ticket Priorities</strong>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<table class="table table-striped mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Title</th>
|
|
||||||
<th class="w-10 text-center">Color</th>
|
|
||||||
<th class="w-10 text-center">Default</th>
|
|
||||||
<th class="w-10 text-center">Order</th>
|
|
||||||
<th class="w-10 text-center">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($this->priorities as $p): ?>
|
|
||||||
<tr>
|
|
||||||
<td><?php echo $this->escape($p->title); ?> <small class="text-muted">(<?php echo $this->escape($p->alias); ?>)</small></td>
|
|
||||||
<td class="text-center"><span class="badge <?php echo $this->escape($p->color); ?>"> </span></td>
|
|
||||||
<td class="text-center"><?php echo $p->is_default ? '<span class="badge bg-success">Yes</span>' : ''; ?></td>
|
|
||||||
<td class="text-center"><?php echo (int) $p->ordering; ?></td>
|
|
||||||
<td class="text-center">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="editPriority(<?php echo htmlspecialchars(json_encode($p)); ?>)">
|
|
||||||
<span class="icon-pencil"></span>
|
|
||||||
</button>
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.deletePriority&id=' . $p->id . '&' . $token . '=1'); ?>"
|
|
||||||
class="btn btn-sm btn-outline-danger"
|
|
||||||
onclick="return confirm('Delete this priority?')">
|
|
||||||
<span class="icon-trash"></span>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<form method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.savePriority'); ?>" id="priorityForm" class="row g-2 align-items-end">
|
|
||||||
<input type="hidden" name="id" id="priority-id" value="0">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label small">Title</label>
|
|
||||||
<input type="text" name="title" id="priority-title" class="form-control form-control-sm" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small">Alias</label>
|
|
||||||
<input type="text" name="alias" id="priority-alias" class="form-control form-control-sm">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small">Color</label>
|
|
||||||
<select name="color" id="priority-color" class="form-select form-select-sm">
|
|
||||||
<?php foreach ($colorOptions as $c): ?>
|
|
||||||
<option value="<?php echo $c; ?>"><?php echo $c; ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1">
|
|
||||||
<label class="form-label small">Order</label>
|
|
||||||
<input type="number" name="ordering" id="priority-ordering" class="form-control form-control-sm" value="0">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1 text-center">
|
|
||||||
<label class="form-label small">Default</label>
|
|
||||||
<input type="checkbox" name="is_default" id="priority-default" value="1" class="form-check-input">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
|
||||||
<button type="submit" class="btn btn-sm btn-primary w-100" id="priority-btn">Add</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function editStatus(s) {
|
|
||||||
document.getElementById('status-id').value = s.id;
|
|
||||||
document.getElementById('status-title').value = s.title;
|
|
||||||
document.getElementById('status-alias').value = s.alias;
|
|
||||||
document.getElementById('status-color').value = s.color;
|
|
||||||
document.getElementById('status-ordering').value = s.ordering;
|
|
||||||
document.getElementById('status-default').checked = !!parseInt(s.is_default);
|
|
||||||
document.getElementById('status-closed').checked = !!parseInt(s.is_closed);
|
|
||||||
document.getElementById('status-btn').textContent = 'Update';
|
|
||||||
}
|
|
||||||
function editPriority(p) {
|
|
||||||
document.getElementById('priority-id').value = p.id;
|
|
||||||
document.getElementById('priority-title').value = p.title;
|
|
||||||
document.getElementById('priority-alias').value = p.alias;
|
|
||||||
document.getElementById('priority-color').value = p.color;
|
|
||||||
document.getElementById('priority-ordering').value = p.ordering;
|
|
||||||
document.getElementById('priority-default').checked = !!parseInt(p.is_default);
|
|
||||||
document.getElementById('priority-btn').textContent = 'Update';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<html><body bgcolor="#FFFFFF"></body></html>
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,28 +3,43 @@
|
|||||||
* @package com_mokosuiteclient
|
* @package com_mokosuiteclient
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Info bar */
|
/* Info bar — full-width with evenly distributed items */
|
||||||
.mokosuiteclient-info-bar .card-body {
|
.mokosuiteclient-info-bar .card-body {
|
||||||
padding: 1rem 1.5rem;
|
padding: 0.75rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mokosuiteclient-info-item {
|
.mokosuiteclient-info-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.35rem;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mokosuiteclient-info-label {
|
.mokosuiteclient-info-label {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.75rem;
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mokosuiteclient-info-value {
|
.mokosuiteclient-info-value {
|
||||||
font-size: 0.875rem;
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mokosuiteclient-info-value .badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Plugin cards */
|
/* Plugin cards */
|
||||||
.mokosuiteclient-plugin-card {
|
.mokosuiteclient-plugin-card {
|
||||||
transition: box-shadow 0.15s ease, opacity 0.15s ease;
|
transition: box-shadow 0.15s ease, opacity 0.15s ease;
|
||||||
|
|||||||
@@ -110,6 +110,40 @@ 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';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Akeeba import buttons
|
// Akeeba import buttons
|
||||||
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
|
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
|
||||||
var btn = document.getElementById(id);
|
var btn = document.getElementById(id);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</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>
|
<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>
|
<namespace path="src">Moko\Component\MokoSuiteClient</namespace>
|
||||||
|
|||||||
@@ -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>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>MOD_MOKOSUITECLIENT_CACHE_DESC</description>
|
<description>MOD_MOKOSUITECLIENT_CACHE_DESC</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteClientCache</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteClientCache</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -11,34 +11,25 @@ defined('_JEXEC') or die;
|
|||||||
use Joomla\CMS\Session\Session;
|
use Joomla\CMS\Session\Session;
|
||||||
|
|
||||||
$token = Session::getFormToken();
|
$token = Session::getFormToken();
|
||||||
$cacheUrl = 'index.php?option=com_mokosuiteclient&task=clearCache&format=json';
|
$cacheUrl = 'index.php?option=com_mokosuiteclient&task=display.clearCache&format=json';
|
||||||
$tempUrl = 'index.php?option=com_mokosuiteclient&task=clearTemp&format=json';
|
$tempUrl = 'index.php?option=com_mokosuiteclient&task=display.clearTemp&format=json';
|
||||||
$domain = $domain ?? '';
|
$domain = $domain ?? '';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<style>
|
<div class="header-item">
|
||||||
.mokosuiteclient-cleaner { display:flex; align-items:center; gap:0; padding:0 0.25rem; }
|
<div class="header-item-content d-flex align-items-center gap-0" style="padding:0;">
|
||||||
.mokosuiteclient-cleaner-label { font-size:0.8rem; color:var(--template-text-dark,#495057); white-space:nowrap; padding-inline-end:0.35rem; }
|
<?php if ($domain): ?>
|
||||||
.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; }
|
<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;">
|
||||||
.mokosuiteclient-cleaner-btn:hover { background:rgba(0,0,0,0.08); color:var(--template-text-dark,#212529); text-decoration:none; }
|
<span class="icon-key" aria-hidden="true"></span> <?php echo htmlspecialchars($domain); ?>
|
||||||
.mokosuiteclient-cleaner-sep { color:var(--template-text-dark,#adb5bd); padding:0 0.1rem; font-size:0.8rem; }
|
</a>
|
||||||
.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; }
|
<?php endif; ?>
|
||||||
.mokosuiteclient-domain:hover { background:rgba(0,0,0,0.06); }
|
<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;">
|
||||||
</style>
|
<span class="icon-bolt" aria-hidden="true" id="mokosuiteclient-cache-icon"></span> Cache
|
||||||
|
</a>
|
||||||
<div class="header-item-content mokosuiteclient-cleaner">
|
<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;">
|
||||||
<?php if ($domain): ?>
|
<span class="icon-trash" aria-hidden="true" id="mokosuiteclient-temp-icon"></span> Temp
|
||||||
<span class="mokosuiteclient-domain" id="mokosuiteclient-domain" title="Support key — click to copy"><?php echo htmlspecialchars($domain); ?></span>
|
</a>
|
||||||
<span class="mokosuiteclient-cleaner-sep">|</span>
|
</div>
|
||||||
<?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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>MOD_MOKOSUITECLIENT_CATEGORIES_DESC</description>
|
<description>MOD_MOKOSUITECLIENT_CATEGORIES_DESC</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteClientCategories</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteClientCategories</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>MOD_MOKOSUITECLIENT_CPANEL_DESC</description>
|
<description>MOD_MOKOSUITECLIENT_CPANEL_DESC</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteClientCpanel</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteClientCpanel</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ class CpanelHelper
|
|||||||
$pkgCache = json_decode($db->loadResult() ?? '{}');
|
$pkgCache = json_decode($db->loadResult() ?? '{}');
|
||||||
|
|
||||||
return (object) [
|
return (object) [
|
||||||
|
'sitename' => $config->get('sitename', ''),
|
||||||
'mokosuiteclient_version' => $pkgCache->version ?? '',
|
'mokosuiteclient_version' => $pkgCache->version ?? '',
|
||||||
'joomla_version' => (new Version())->getShortVersion(),
|
'joomla_version' => (new Version())->getShortVersion(),
|
||||||
'php_version' => PHP_VERSION,
|
'php_version' => PHP_VERSION,
|
||||||
|
'db_type' => $config->get('dbtype', 'mysql'),
|
||||||
'debug' => (bool) $config->get('debug'),
|
'debug' => (bool) $config->get('debug'),
|
||||||
'offline' => (bool) $config->get('offline'),
|
'offline' => (bool) $config->get('offline'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\Router\Route;
|
use Joomla\CMS\Router\Route;
|
||||||
use Joomla\CMS\Session\Session;
|
use Joomla\CMS\Session\Session;
|
||||||
@@ -44,10 +45,13 @@ foreach ($plugins as $p)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$labels = [
|
$labels = [
|
||||||
'mokosuiteclient' => 'Core',
|
'mokosuiteclient' => 'Core Engine',
|
||||||
'mokosuiteclient_firewall' => 'Firewall',
|
'mokosuiteclient_firewall' => 'Web Firewall',
|
||||||
'mokosuiteclient_tenant' => 'Tenant',
|
'mokosuiteclient_tenant' => 'Tenant Guard',
|
||||||
'mokosuiteclient_devtools' => 'DevTools',
|
'mokosuiteclient_devtools' => 'Dev Tools',
|
||||||
|
'mokosuiteclient_offline' => 'Offline Bypass',
|
||||||
|
'mokosuiteclient_dbip' => 'GeoIP Lookup',
|
||||||
|
'mokosuiteclient_license' => 'License Manager',
|
||||||
];
|
];
|
||||||
|
|
||||||
$diskPct = ($disk->total_mb && $disk->total_mb > 0)
|
$diskPct = ($disk->total_mb && $disk->total_mb > 0)
|
||||||
@@ -57,176 +61,30 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="mod-mokosuiteclient-cpanel card p-3 mb-4">
|
<div class="mod-mokosuiteclient-cpanel card p-3 mb-4">
|
||||||
<!-- Header row -->
|
<div class="d-flex flex-wrap align-items-center gap-2" style="font-size:0.85rem;">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<?php $canDashboard = Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokosuiteclient'); ?>
|
||||||
<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;">
|
<?php if ($canDashboard): ?>
|
||||||
<span class="fa-solid fa-caret-<?php echo $collapsed ? 'right' : 'down'; ?>" aria-hidden="true" id="mokosuiteclient-cpanel-caret"></span>
|
<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>
|
||||||
</button>
|
<?php else: ?>
|
||||||
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.25rem;color:#1a2744"></span>
|
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.1rem;color:#1a2744"></span>
|
||||||
<strong>MokoSuiteClient</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>
|
|
||||||
<?php endif; ?>
|
<?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="Daily verification PIN — rotates at midnight UTC."><span class="icon-key small me-1" aria-hidden="true"></span><?php echo htmlspecialchars($supportPin); ?></span>
|
||||||
|
<?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)): ?>
|
<?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 endif; ?>
|
||||||
<?php if (!empty($siteInfo->offline)): ?>
|
<?php if (!empty($siteInfo->offline)): ?>
|
||||||
<span class="badge bg-danger">Offline</span>
|
<span class="badge bg-danger">Offline</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (($counts->moko_updates ?? 0) > 0): ?>
|
<span class="ms-auto d-flex align-items-center gap-2">
|
||||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-info text-decoration-none" title="MokoSuiteClient updates available">
|
<span class="icon-globe" aria-hidden="true"></span>
|
||||||
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->moko_updates; ?> MokoSuiteClient update<?php echo $counts->moko_updates > 1 ? 's' : ''; ?>
|
<code><?php echo htmlspecialchars($currentIp); ?></code>
|
||||||
</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>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var btn = document.getElementById('mokosuiteclient-cpanel-cache');
|
|
||||||
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';
|
|
||||||
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:['Cache cleared.']});
|
|
||||||
else Joomla.renderMessages({error:[d.message||'Failed']});
|
|
||||||
})
|
|
||||||
.catch(function(){Joomla.renderMessages({error:['Network error']})})
|
|
||||||
.finally(function(){
|
|
||||||
el.disabled = false;
|
|
||||||
if (icon) icon.className = origClass;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu.</description>
|
<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>
|
<namespace path="src">Moko\Module\MokoSuiteClientMenu</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,26 @@ $app = Factory::getApplication();
|
|||||||
$currentOption = $app->getInput()->get('option', '');
|
$currentOption = $app->getInput()->get('option', '');
|
||||||
$currentView = $app->getInput()->get('view', '');
|
$currentView = $app->getInput()->get('view', '');
|
||||||
|
|
||||||
// ── Static views for com_mokosuiteclient ──────────────────────────────────
|
// ── Static views for com_mokosuiteclient (ACL-gated) ──────────────────────
|
||||||
$mokosuiteclientStaticViews = [
|
$user = $app->getIdentity();
|
||||||
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'],
|
$allViews = [
|
||||||
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuiteclient&view=tickets'],
|
['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'],
|
['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'],
|
['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokosuiteclient&view=htaccess', 'acl' => 'mokosuiteclient.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', 'acl' => 'mokosuiteclient.security.waflog'],
|
||||||
['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokosuiteclient&view=waflog'],
|
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy', 'acl' => 'core.admin'],
|
||||||
['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokosuiteclient&view=database'],
|
['icon' => 'fa-solid fa-code', 'title' => 'Snippets', 'link' => 'index.php?option=com_mokosuiteclient&view=snippets', 'acl' => 'mokosuiteclient.snippets.manage'],
|
||||||
['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokosuiteclient&view=cleanup'],
|
['icon' => 'fa-solid fa-file-lines', 'title' => 'Templates', 'link' => 'index.php?option=com_mokosuiteclient&view=templates', 'acl' => 'mokosuiteclient.templates.manage'],
|
||||||
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient'],
|
['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 ──────────────────
|
// ── Auto-discover all Moko components from #__menu ──────────────────
|
||||||
$mokoComponents = [];
|
$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.77
|
||||||
|
* 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
|
* FILE INFORMATION
|
||||||
* DEFGROUP: Joomla.Plugin
|
* DEFGROUP: Joomla.Plugin
|
||||||
* INGROUP: MokoSuiteClient
|
* INGROUP: MokoSuiteClient
|
||||||
* VERSION: 02.46.80
|
* VERSION: 02.47.77
|
||||||
* PATH: /src/Field/CopyableTokenField.php
|
* PATH: /src/Field/CopyableTokenField.php
|
||||||
* BRIEF: Read-only token field with a copy-to-clipboard button
|
* 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>
|
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
|
<description>MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
|
||||||
<namespace path=".">Moko\Plugin\System\MokoSuiteClient</namespace>
|
<namespace path=".">Moko\Plugin\System\MokoSuiteClient</namespace>
|
||||||
<scriptfile>script.php</scriptfile>
|
<scriptfile>script.php</scriptfile>
|
||||||
@@ -99,7 +99,94 @@
|
|||||||
description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC"
|
description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC"
|
||||||
filter="url" />
|
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"
|
||||||
|
label="Forbidden PHP Functions"
|
||||||
|
description="Comma-separated list of PHP functions blocked in {source} tags."
|
||||||
|
showon="sourcerer_enabled:1" />
|
||||||
|
|
||||||
|
<field name="monitor_signing_key" type="hidden"
|
||||||
default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQ2xZNnNzOTZpeTZOOGMKTHRxbndhbnU4eEozdDcrdDhXT3hoY0Yyclc2QmlmOVhNaEpnYkw0c055N0wwV1dTT2tkMmZxalBNcDFtOFNyNAo1VnNycjE3cFc5b0FNMmtmdFdsaTZ1NkhTVEYyN2pVVUJrT3o4MHZMRklMMGNGNkJCUkpYN2JVWkRpamdUMjc1ClREb3dXZy82Zk9GeWFEelBHUkJuYXFacTljU2lEYWoyNlpSTVZIbktQUERWTG92VzRPTDQzL2gwZ3BtN25nUGIKdWJlLzFFTDRUMHFRbm1Xc2FEOFZ6VStoRXFGSDRTVUtMaDVNeklGbUxFZzRlZ0xCbTBXcWdxbzZRQVBnZDVPYgoybXhmQndta3RLVm5hcWR6eG9KSytzaTVuZkYreGpxbWRMZThUdmEyTHNuTUxlZmsrODVoQ3hxS2x1eWRta1lXCjlvUk5qcDhiQWdNQkFBRUNnZ0VBQkZOUS9NSVZaV2gxdlZUMFh3TFBvUEkyZjI4TTBrM0gzN0t4MXBxK2t5QzYKenRyK1pBczBCaEFEWjAwNHJOUmRYaG45N0QxVXBJYVdLeUJFZkNZQUEzWmxneS9WQmdGR21sR3VuMWNvdGdXUQoyYzg0SWhLdzNzVFFqL2dJWUxOelFWMTBLUTJYd0JZVHZ1MWhjRFpLeUxCUGJTQ1F4cEhQUGdVcUNRNFljR3lFClErVmc1dHJUYk8wQ2xCZ1U5bkVnYU1RakRJZ0F3WVZPV203dUxJTW84UC9nT3FuT2tmaFhzdzl3VTJVYWxFeTEKRmRZbGhMbGJ0ZS9MZ3lkYlJ2RStjNEtqZVp0Z3ptc1RneEh2dzM5YVVmZUZTclFRT0FjcXc0alNzUjdMck9UZAp5bDhpelRrZVBrTVFMamFqR0pabWdPbitkRzhtUlpMa3FKcWdGaVpqRVFLQmdRRFV0L0xlU0h5SmhvY3VFL240CkZreEpaclJoWUVsWnc2WlZJUnQzWDlPQ1Nmaklab3I1ZkZlczhvUzZySFhKdGZYeWx4QUxOSjJjTUhKTTViVnUKbUFSUFU4cThBeVc0OE03cHAyNmtVVTMxNXc2OU1SUkhzbWgyekRabEtDeG5GM1NSQ3U4YW95d3hZc3RUZ3hkTgo2bDhLNHZsS1dsN3FYblBhWjZjb3lQSU9od0tCZ1FESENuRmRRdW5SMVI2dkxGaVFZMTRiT3QwT0tzVGJYMUJyCmpvUGZySkxvRm5mSCs4VDVnNUdxYkV5T2p0WG1tRXhmTFFpcDBQVXRtc1E0YXlJRFBZYWZtU3RpK2dtQXZFd1MKZTlKcVYxYlRuazUrYnVRZ2FlOW16REpJWkxaczRJUlhrd1Q5aDZ4Q2xKeS80TGJSRHdBU3dUVGJlY01hN3A4UgpQN0p0bjdsYnpRS0JnQzNOR2FjUTFuZktGb3N1VS9FOTQ5a2VHeEtvWjhMREpLcEp3WjgzYTlRdTF6bFhFdTlhCi9ZbklnaG1yam9VSy85VG0vOVpaMHVIUmNKcnNEdCtzTGFsaThsRC9JSDBzcEhDYzAyN2Y3cmhXc3M2N3BaRTIKY2RXNmJLL2xNWUpWQTQxRFhHNVEyZkFjUklsTHZaWFNNL3FsR21ZUEJVYlRaWUNPTnVqS000dzdBb0dBU1dBdwpLcEZnWVZxUDFVUWo0aGEvdW9vWXRBQlFVZzd4TnJWektDSVdoampDTDVkQkpqcTZtSGtVUC9tb0lUcEQ3VkpNCnYwMnBGUWJaRDNOdk5vS1gvbjRZNElRTXZNaXR3cUtqRDFEalVXQXF6N0ZScUNGbGdDQUc2V2szVnl2dG5kczEKRzhISVgwTXFCaEp4VXVDVXhsVXpoelY4RjVHZ1VsdUpDNkMyVklFQ2dZQkJWSkxpZlNVOTlHWGZtK3dPd0RWcgo2bHZoUFgxOTBGVktWQXY3aVVWTXBwWXg4Y0QxYkcyUjRLT29JbnkxYTlxdjA2ZGFzeGVQOStkVjJVMWU3MWl5CkFXWDRBVHIrYitvSGk2eUk1MXRHRk54RUxiNXZYMVpYM3VNaDlWM29iYUpuSFNjYllpKzBBNjlyRmNuNEZuLzUKWXJybWxLTzRlRHFVZkswbVFJVCtwUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"
|
default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQ2xZNnNzOTZpeTZOOGMKTHRxbndhbnU4eEozdDcrdDhXT3hoY0Yyclc2QmlmOVhNaEpnYkw0c055N0wwV1dTT2tkMmZxalBNcDFtOFNyNAo1VnNycjE3cFc5b0FNMmtmdFdsaTZ1NkhTVEYyN2pVVUJrT3o4MHZMRklMMGNGNkJCUkpYN2JVWkRpamdUMjc1ClREb3dXZy82Zk9GeWFEelBHUkJuYXFacTljU2lEYWoyNlpSTVZIbktQUERWTG92VzRPTDQzL2gwZ3BtN25nUGIKdWJlLzFFTDRUMHFRbm1Xc2FEOFZ6VStoRXFGSDRTVUtMaDVNeklGbUxFZzRlZ0xCbTBXcWdxbzZRQVBnZDVPYgoybXhmQndta3RLVm5hcWR6eG9KSytzaTVuZkYreGpxbWRMZThUdmEyTHNuTUxlZmsrODVoQ3hxS2x1eWRta1lXCjlvUk5qcDhiQWdNQkFBRUNnZ0VBQkZOUS9NSVZaV2gxdlZUMFh3TFBvUEkyZjI4TTBrM0gzN0t4MXBxK2t5QzYKenRyK1pBczBCaEFEWjAwNHJOUmRYaG45N0QxVXBJYVdLeUJFZkNZQUEzWmxneS9WQmdGR21sR3VuMWNvdGdXUQoyYzg0SWhLdzNzVFFqL2dJWUxOelFWMTBLUTJYd0JZVHZ1MWhjRFpLeUxCUGJTQ1F4cEhQUGdVcUNRNFljR3lFClErVmc1dHJUYk8wQ2xCZ1U5bkVnYU1RakRJZ0F3WVZPV203dUxJTW84UC9nT3FuT2tmaFhzdzl3VTJVYWxFeTEKRmRZbGhMbGJ0ZS9MZ3lkYlJ2RStjNEtqZVp0Z3ptc1RneEh2dzM5YVVmZUZTclFRT0FjcXc0alNzUjdMck9UZAp5bDhpelRrZVBrTVFMamFqR0pabWdPbitkRzhtUlpMa3FKcWdGaVpqRVFLQmdRRFV0L0xlU0h5SmhvY3VFL240CkZreEpaclJoWUVsWnc2WlZJUnQzWDlPQ1Nmaklab3I1ZkZlczhvUzZySFhKdGZYeWx4QUxOSjJjTUhKTTViVnUKbUFSUFU4cThBeVc0OE03cHAyNmtVVTMxNXc2OU1SUkhzbWgyekRabEtDeG5GM1NSQ3U4YW95d3hZc3RUZ3hkTgo2bDhLNHZsS1dsN3FYblBhWjZjb3lQSU9od0tCZ1FESENuRmRRdW5SMVI2dkxGaVFZMTRiT3QwT0tzVGJYMUJyCmpvUGZySkxvRm5mSCs4VDVnNUdxYkV5T2p0WG1tRXhmTFFpcDBQVXRtc1E0YXlJRFBZYWZtU3RpK2dtQXZFd1MKZTlKcVYxYlRuazUrYnVRZ2FlOW16REpJWkxaczRJUlhrd1Q5aDZ4Q2xKeS80TGJSRHdBU3dUVGJlY01hN3A4UgpQN0p0bjdsYnpRS0JnQzNOR2FjUTFuZktGb3N1VS9FOTQ5a2VHeEtvWjhMREpLcEp3WjgzYTlRdTF6bFhFdTlhCi9ZbklnaG1yam9VSy85VG0vOVpaMHVIUmNKcnNEdCtzTGFsaThsRC9JSDBzcEhDYzAyN2Y3cmhXc3M2N3BaRTIKY2RXNmJLL2xNWUpWQTQxRFhHNVEyZkFjUklsTHZaWFNNL3FsR21ZUEJVYlRaWUNPTnVqS000dzdBb0dBU1dBdwpLcEZnWVZxUDFVUWo0aGEvdW9vWXRBQlFVZzd4TnJWektDSVdoampDTDVkQkpqcTZtSGtVUC9tb0lUcEQ3VkpNCnYwMnBGUWJaRDNOdk5vS1gvbjRZNElRTXZNaXR3cUtqRDFEalVXQXF6N0ZScUNGbGdDQUc2V2szVnl2dG5kczEKRzhISVgwTXFCaEp4VXVDVXhsVXpoelY4RjVHZ1VsdUpDNkMyVklFQ2dZQkJWSkxpZlNVOTlHWGZtK3dPd0RWcgo2bHZoUFgxOTBGVktWQXY3aVVWTXBwWXg4Y0QxYkcyUjRLT29JbnkxYTlxdjA2ZGFzeGVQOStkVjJVMWU3MWl5CkFXWDRBVHIrYitvSGk2eUk1MXRHRk54RUxiNXZYMVpYM3VNaDlWM29iYUpuSFNjYllpKzBBNjlyRmNuNEZuLzUKWXJybWxLTzRlRHFVZkswbVFJVCtwUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"
|
||||||
filter="raw" />
|
filter="raw" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* DEFGROUP: Joomla.Plugin
|
* DEFGROUP: Joomla.Plugin
|
||||||
* INGROUP: MokoSuiteClient
|
* INGROUP: MokoSuiteClient
|
||||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
* VERSION: 02.46.80
|
* VERSION: 02.47.77
|
||||||
* PATH: /src/script.php
|
* PATH: /src/script.php
|
||||||
* BRIEF: Installation script for MokoSuiteClient plugin
|
* BRIEF: Installation script for MokoSuiteClient plugin
|
||||||
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* DEFGROUP: Joomla.Plugin
|
* DEFGROUP: Joomla.Plugin
|
||||||
* INGROUP: MokoSuiteClient
|
* INGROUP: MokoSuiteClient
|
||||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
* VERSION: 02.46.80
|
* VERSION: 02.47.77
|
||||||
* PATH: /src/services/provider.php
|
* PATH: /src/services/provider.php
|
||||||
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
||||||
* NOTE: Registers the plugin with Joomla's DI container
|
* NOTE: Registers the plugin with Joomla's DI container
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientBackup</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientBackup</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDBIP</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDBIP</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<form>
|
||||||
|
<field name="domain" type="text"
|
||||||
|
label="Domain"
|
||||||
|
hint="dev.clientsite.com"
|
||||||
|
filter="raw"
|
||||||
|
required="true" />
|
||||||
|
|
||||||
|
<field name="offline_bypass" type="list" default="1"
|
||||||
|
label="Offline Mode">
|
||||||
|
<option value="1">Bypass (stay online)</option>
|
||||||
|
<option value="0">Respect (go offline)</option>
|
||||||
|
</field>
|
||||||
|
|
||||||
|
<field name="robots" type="list" default="noindex, nofollow"
|
||||||
|
label="Robots">
|
||||||
|
<option value="noindex, nofollow">noindex, nofollow</option>
|
||||||
|
<option value="noindex">noindex</option>
|
||||||
|
<option value="index, follow">index, follow (production)</option>
|
||||||
|
</field>
|
||||||
|
|
||||||
|
<field name="label" type="text"
|
||||||
|
label="Label"
|
||||||
|
hint="Development, Staging, QA..."
|
||||||
|
filter="string" />
|
||||||
|
</form>
|
||||||
+6
-10
@@ -15,14 +15,10 @@ 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_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_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_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_DEVDOMAIN="Dev Domain"
|
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES="Mirror Domains & Staging Environments"
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_DEVDOMAIN_DESC="Configure a development domain alias that bypasses offline mode and has its own robots settings."
|
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."
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ENABLED_LABEL="Enable Dev Domain"
|
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_LABEL="Domain Mirrors"
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ENABLED_DESC="Allow a development domain to bypass offline mode for testing."
|
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_DESC="Add CNAME domains for development, staging, or QA. Each mirror gets its own offline and robots settings while sharing the same database and files."
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_LABEL="Dev Domain"
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_DESC="The development domain alias. Leave empty to auto-detect as dev.{primary_domain}. Must point to the same hosting folder."
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_BYPASS_LABEL="Bypass Offline Mode"
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_BYPASS_DESC="When the main site is offline, the dev domain stays accessible for development and testing."
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ROBOTS_LABEL="Robots Directive"
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ROBOTS_DESC="Meta robots tag for the dev domain. Use noindex,nofollow to prevent search engines from indexing the dev site."
|
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDevTools</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDevTools</namespace>
|
||||||
|
|
||||||
<files>
|
<files>
|
||||||
<folder>src</folder>
|
<folder>src</folder>
|
||||||
<folder>services</folder>
|
<folder>services</folder>
|
||||||
|
<folder>forms</folder>
|
||||||
<folder>language</folder>
|
<folder>language</folder>
|
||||||
</files>
|
</files>
|
||||||
|
|
||||||
@@ -60,43 +61,28 @@
|
|||||||
<option value="1">JYES</option>
|
<option value="1">JYES</option>
|
||||||
<option value="0">JNO</option>
|
<option value="0">JNO</option>
|
||||||
</field>
|
</field>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset name="dev_domain"
|
<field name="reset_tour_prompts" type="radio" default="0"
|
||||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_DEVDOMAIN"
|
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_TOURS_LABEL"
|
||||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_DEVDOMAIN_DESC">
|
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_TOURS_DESC"
|
||||||
|
|
||||||
<field name="dev_domain_enabled" type="radio" default="1"
|
|
||||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ENABLED_LABEL"
|
|
||||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ENABLED_DESC"
|
|
||||||
class="btn-group btn-group-yesno">
|
class="btn-group btn-group-yesno">
|
||||||
<option value="1">JYES</option>
|
<option value="1">JYES</option>
|
||||||
<option value="0">JNO</option>
|
<option value="0">JNO</option>
|
||||||
</field>
|
</field>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<field name="dev_domain" type="text" default=""
|
<fieldset name="site_aliases"
|
||||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_LABEL"
|
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES"
|
||||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_DESC"
|
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES_DESC">
|
||||||
hint="dev.clientsite.com (auto-detected from primary domain if empty)"
|
|
||||||
showon="dev_domain_enabled:1" />
|
|
||||||
|
|
||||||
<field name="dev_domain_offline_bypass" type="radio" default="1"
|
<field name="site_aliases" type="subform"
|
||||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_BYPASS_LABEL"
|
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_LABEL"
|
||||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_BYPASS_DESC"
|
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_DESC"
|
||||||
class="btn-group btn-group-yesno"
|
formsource="plugins/system/mokosuiteclient_devtools/forms/site_alias_entry.xml"
|
||||||
showon="dev_domain_enabled:1">
|
multiple="true"
|
||||||
<option value="1">JYES</option>
|
layout="joomla.form.field.subform.repeatable-table"
|
||||||
<option value="0">JNO</option>
|
groupByFieldset="false"
|
||||||
</field>
|
buttons="add,remove,move" />
|
||||||
|
|
||||||
<field name="dev_domain_robots" type="list" default="noindex, nofollow"
|
|
||||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ROBOTS_LABEL"
|
|
||||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ROBOTS_DESC"
|
|
||||||
showon="dev_domain_enabled:1">
|
|
||||||
<option value="noindex, nofollow">noindex, nofollow</option>
|
|
||||||
<option value="noindex">noindex</option>
|
|
||||||
<option value="index, follow">index, follow</option>
|
|
||||||
</field>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</fields>
|
</fields>
|
||||||
</config>
|
</config>
|
||||||
|
|||||||
@@ -58,21 +58,13 @@ class DevTools extends CMSPlugin implements SubscriberInterface
|
|||||||
$config->set('offline', 1);
|
$config->set('offline', 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppress hit recording
|
// Suppress hit recording via com_content's native parameter
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$contentParams = \Joomla\CMS\Component\ComponentHelper::getParams('com_content');
|
||||||
$db->setQuery(
|
$contentParams->set('record_hits', 0);
|
||||||
$db->getQuery(true)
|
|
||||||
->update($db->quoteName('#__content'))
|
|
||||||
->set($db->quoteName('hits') . ' = 0')
|
|
||||||
->where($db->quoteName('hits') . ' > 0')
|
|
||||||
)->execute();
|
|
||||||
}
|
|
||||||
catch (\Throwable $e)
|
|
||||||
{
|
|
||||||
// Silent
|
|
||||||
}
|
}
|
||||||
|
catch (\Throwable $e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,6 +118,13 @@ class DevTools extends CMSPlugin implements SubscriberInterface
|
|||||||
$params->set('reset_download_keys', 0);
|
$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
|
// Reset the one-shot toggles
|
||||||
if ($table->params !== $params->toString())
|
if ($table->params !== $params->toString())
|
||||||
{
|
{
|
||||||
@@ -168,6 +167,21 @@ class DevTools extends CMSPlugin implements SubscriberInterface
|
|||||||
return $count;
|
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
|
private function resetDownloadKeys(): int
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientFirewall</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientFirewall</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -20,23 +20,29 @@ class CurrentIpField extends FormField
|
|||||||
|
|
||||||
protected function getInput(): string
|
protected function getInput(): string
|
||||||
{
|
{
|
||||||
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
$forwarded = '';
|
||||||
|
|
||||||
if (!empty($ip))
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']))
|
||||||
{
|
{
|
||||||
$ip = trim(explode(',', $ip)[0]);
|
$candidate = trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
|
||||||
|
if (filter_var($candidate, FILTER_VALIDATE_IP))
|
||||||
|
{
|
||||||
|
$forwarded = $candidate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($ip))
|
$html = '<div class="d-flex align-items-center gap-2">'
|
||||||
{
|
|
||||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '<div class="d-flex align-items-center gap-2">'
|
|
||||||
. '<code style="font-size:1.1rem;padding:0.4rem 0.8rem;background:#f8f9fa;border:1px solid #dee2e6;border-radius:4px;" id="mokosuiteclient-current-ip">'
|
. '<code style="font-size:1.1rem;padding:0.4rem 0.8rem;background:#f8f9fa;border:1px solid #dee2e6;border-radius:4px;" id="mokosuiteclient-current-ip">'
|
||||||
. htmlspecialchars($ip)
|
. htmlspecialchars($ip)
|
||||||
. '</code>'
|
. '</code>'
|
||||||
. '<button type="button" class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById(\'mokosuiteclient-current-ip\').textContent.trim()).then(function(){this.textContent=\'Copied!\';var b=this;setTimeout(function(){b.textContent=\'Copy\'},1500)}.bind(this))" title="Copy IP to clipboard">Copy</button>'
|
. '<button type="button" class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById(\'mokosuiteclient-current-ip\').textContent.trim()).then(function(){this.textContent=\'Copied!\';var b=this;setTimeout(function(){b.textContent=\'Copy\'},1500)}.bind(this))" title="Copy IP to clipboard">Copy</button>';
|
||||||
. '</div>';
|
|
||||||
|
if ($forwarded && $forwarded !== $ip)
|
||||||
|
{
|
||||||
|
$html .= '<span class="text-muted small ms-2">Proxy: <code>' . htmlspecialchars($forwarded) . '</code></span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html . '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientLicense</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientLicense</namespace>
|
||||||
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
|
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientOffline</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientOffline</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientTenant</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientTenant</namespace>
|
||||||
|
|
||||||
|
|||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
PLG_TASK_MOKOSUITECLIENT_TICKETS="Task - MokoSuiteClient Ticket Automation"
|
|
||||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_DESC="Runs scheduled helpdesk automation rules."
|
|
||||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_TITLE="MokoSuiteClient: Ticket Automation"
|
|
||||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)."
|
|
||||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_TITLE="MokoSuiteClient: IMAP Email Polling"
|
|
||||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_DESC="Polls an IMAP inbox for new emails and creates tickets or replies from unread messages."
|
|
||||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_TITLE="MokoSuiteClient: Auto-Close Resolved Tickets"
|
|
||||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_DESC="Automatically closes tickets that have been in resolved status longer than the configured number of days."
|
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
PLG_TASK_MOKOSUITECLIENT_TICKETS="Task - MokoSuiteClient Ticket Automation"
|
|
||||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_DESC="Runs scheduled helpdesk automation rules — auto-close, SLA escalation, and time-based actions."
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<extension type="plugin" group="task" method="upgrade">
|
|
||||||
<name>Task - MokoSuiteClient Ticket Automation</name>
|
|
||||||
<element>mokosuiteclient_tickets</element>
|
|
||||||
<author>Moko Consulting</author>
|
|
||||||
<creationDate>2026-06-02</creationDate>
|
|
||||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
|
||||||
<license>GPL-3.0-or-later</license>
|
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
|
||||||
<version>02.46.80</version>
|
|
||||||
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
|
|
||||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientTickets</namespace>
|
|
||||||
|
|
||||||
<files>
|
|
||||||
<folder>src</folder>
|
|
||||||
<folder>services</folder>
|
|
||||||
<folder>language</folder>
|
|
||||||
</files>
|
|
||||||
|
|
||||||
<languages folder="language">
|
|
||||||
<language tag="en-GB">en-GB/plg_task_mokosuiteclient_tickets.ini</language>
|
|
||||||
<language tag="en-GB">en-GB/plg_task_mokosuiteclient_tickets.sys.ini</language>
|
|
||||||
</languages>
|
|
||||||
</extension>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Extension\PluginInterface;
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Plugin\PluginHelper;
|
|
||||||
use Joomla\DI\Container;
|
|
||||||
use Joomla\DI\ServiceProviderInterface;
|
|
||||||
use Joomla\Event\DispatcherInterface;
|
|
||||||
use Moko\Plugin\Task\MokoSuiteClientTickets\Extension\TicketAutomation;
|
|
||||||
|
|
||||||
return new class implements ServiceProviderInterface
|
|
||||||
{
|
|
||||||
public function register(Container $container): void
|
|
||||||
{
|
|
||||||
$container->set(
|
|
||||||
PluginInterface::class,
|
|
||||||
function (Container $container) {
|
|
||||||
$dispatcher = $container->get(DispatcherInterface::class);
|
|
||||||
$plugin = new TicketAutomation($dispatcher, (array) PluginHelper::getPlugin('task', 'mokosuiteclient_tickets'));
|
|
||||||
$plugin->setApplication(Factory::getApplication());
|
|
||||||
|
|
||||||
return $plugin;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoSuiteClient
|
|
||||||
* @subpackage plg_task_mokosuiteclient_tickets
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Plugin\Task\MokoSuiteClientTickets\Extension;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Log\Log;
|
|
||||||
use Joomla\CMS\Plugin\CMSPlugin;
|
|
||||||
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
|
|
||||||
use Joomla\Component\Scheduler\Administrator\Task\Status;
|
|
||||||
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
|
|
||||||
use Joomla\Event\SubscriberInterface;
|
|
||||||
use Moko\Component\MokoSuiteClient\Administrator\Model\TicketsModel;
|
|
||||||
use Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService;
|
|
||||||
use Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService;
|
|
||||||
|
|
||||||
class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
|
||||||
{
|
|
||||||
use TaskPluginTrait;
|
|
||||||
|
|
||||||
protected const TASKS_MAP = [
|
|
||||||
'mokosuiteclient.ticket.automation' => [
|
|
||||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION',
|
|
||||||
'method' => 'runAutomation',
|
|
||||||
],
|
|
||||||
'mokosuiteclient.ticket.imap_poll' => [
|
|
||||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL',
|
|
||||||
'method' => 'runImapPoll',
|
|
||||||
],
|
|
||||||
'mokosuiteclient.ticket.autoclose' => [
|
|
||||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE',
|
|
||||||
'method' => 'runAutoClose',
|
|
||||||
],
|
|
||||||
'mokosuiteclient.license.validate' => [
|
|
||||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_LICENSE_VALIDATE',
|
|
||||||
'method' => 'runLicenseValidation',
|
|
||||||
],
|
|
||||||
'mokosuiteclient.license.heartbeat' => [
|
|
||||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_LICENSE_HEARTBEAT',
|
|
||||||
'method' => 'runLicenseHeartbeat',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $autoloadLanguage = true;
|
|
||||||
|
|
||||||
public static function getSubscribedEvents(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'onTaskOptionsList' => 'advertiseRoutines',
|
|
||||||
'onExecuteTask' => 'standardRoutineHandler',
|
|
||||||
'onContentPrepareForm' => 'enhanceTaskItemForm',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run all scheduled automation rules against open tickets.
|
|
||||||
*/
|
|
||||||
private function runAutomation(ExecuteTaskEvent $event): int
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
$model = new TicketsModel();
|
|
||||||
$results = $model->runScheduledAutomation();
|
|
||||||
|
|
||||||
$this->logTask(
|
|
||||||
\sprintf('Ticket automation: evaluated %d tickets, acted on %d', $results['evaluated'], $results['acted'])
|
|
||||||
);
|
|
||||||
|
|
||||||
return Status::OK;
|
|
||||||
}
|
|
||||||
catch (\Throwable $e)
|
|
||||||
{
|
|
||||||
$this->logTask('Ticket automation failed: ' . $e->getMessage(), 'error');
|
|
||||||
|
|
||||||
return Status::KNOCKOUT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll IMAP inbox and create tickets from unread emails (#136).
|
|
||||||
*/
|
|
||||||
private function runImapPoll(ExecuteTaskEvent $event): int
|
|
||||||
{
|
|
||||||
$config = $this->getComponentConfig();
|
|
||||||
$host = $config['imap_host'] ?? '';
|
|
||||||
$port = (int) ($config['imap_port'] ?? 993);
|
|
||||||
$user = $config['imap_user'] ?? '';
|
|
||||||
$pass = $config['imap_password'] ?? '';
|
|
||||||
$ssl = ($config['imap_ssl'] ?? '1') === '1';
|
|
||||||
$folder = $config['imap_folder'] ?? 'INBOX';
|
|
||||||
$processed = $config['imap_processed_folder'] ?? 'INBOX.Processed';
|
|
||||||
$defaultCat = (int) ($config['default_category'] ?? 0) ?: null;
|
|
||||||
|
|
||||||
if (empty($host) || empty($user) || empty($pass))
|
|
||||||
{
|
|
||||||
$this->logTask('IMAP not configured — skipping', 'warning');
|
|
||||||
return Status::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!function_exists('imap_open'))
|
|
||||||
{
|
|
||||||
$this->logTask('php-imap extension not available', 'error');
|
|
||||||
return Status::KNOCKOUT;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder;
|
|
||||||
$mbox = @imap_open($mailbox, $user, $pass);
|
|
||||||
|
|
||||||
if (!$mbox)
|
|
||||||
{
|
|
||||||
$this->logTask('IMAP connection failed: ' . imap_last_error(), 'error');
|
|
||||||
return Status::KNOCKOUT;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
|
||||||
$created = 0;
|
|
||||||
$replied = 0;
|
|
||||||
|
|
||||||
$emails = imap_search($mbox, 'UNSEEN');
|
|
||||||
|
|
||||||
if ($emails === false)
|
|
||||||
{
|
|
||||||
imap_close($mbox);
|
|
||||||
$this->logTask('No new emails');
|
|
||||||
return Status::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($emails as $msgNum)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
$header = imap_headerinfo($mbox, $msgNum);
|
|
||||||
$subject = isset($header->subject) ? imap_utf8($header->subject) : '(no subject)';
|
|
||||||
$fromAddr = $header->from[0]->mailbox . '@' . $header->from[0]->host;
|
|
||||||
$body = $this->getImapBody($mbox, $msgNum);
|
|
||||||
|
|
||||||
// Match sender to Joomla user
|
|
||||||
$userId = $this->findUserByEmail($fromAddr);
|
|
||||||
|
|
||||||
// Check if this is a reply (subject contains [#123])
|
|
||||||
$ticketId = 0;
|
|
||||||
if (preg_match('/\[#(\d+)\]/', $subject, $m))
|
|
||||||
{
|
|
||||||
$ticketId = (int) $m[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ticketId > 0)
|
|
||||||
{
|
|
||||||
// Add as reply to existing ticket
|
|
||||||
$reply = (object) [
|
|
||||||
'ticket_id' => $ticketId,
|
|
||||||
'user_id' => $userId,
|
|
||||||
'body' => $body,
|
|
||||||
'is_internal' => 0,
|
|
||||||
'created' => Factory::getDate()->toSql(),
|
|
||||||
];
|
|
||||||
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
|
|
||||||
$replied++;
|
|
||||||
|
|
||||||
// Notify
|
|
||||||
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId));
|
|
||||||
$ticket = $db->loadObject();
|
|
||||||
if ($ticket) {
|
|
||||||
NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Create new ticket
|
|
||||||
$ticket = (object) [
|
|
||||||
'subject' => $subject,
|
|
||||||
'body' => $body,
|
|
||||||
'status' => 'open',
|
|
||||||
'priority' => 'normal',
|
|
||||||
'category_id' => $defaultCat,
|
|
||||||
'created_by' => $userId,
|
|
||||||
'created' => Factory::getDate()->toSql(),
|
|
||||||
];
|
|
||||||
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
|
|
||||||
$created++;
|
|
||||||
|
|
||||||
NotificationService::notify('ticket_created', $ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as seen / move to processed folder
|
|
||||||
imap_setflag_full($mbox, (string) $msgNum, '\\Seen');
|
|
||||||
|
|
||||||
if ($processed && $processed !== $folder)
|
|
||||||
{
|
|
||||||
@imap_mail_move($mbox, (string) $msgNum, $processed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (\Throwable $e)
|
|
||||||
{
|
|
||||||
Log::add('IMAP message processing error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
imap_expunge($mbox);
|
|
||||||
imap_close($mbox);
|
|
||||||
|
|
||||||
$this->logTask("IMAP poll: {$created} tickets created, {$replied} replies added");
|
|
||||||
return Status::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-close resolved tickets after configured days.
|
|
||||||
*/
|
|
||||||
private function runAutoClose(ExecuteTaskEvent $event): int
|
|
||||||
{
|
|
||||||
$config = $this->getComponentConfig();
|
|
||||||
$days = (int) ($config['autoclose_days'] ?? 7);
|
|
||||||
|
|
||||||
if ($days <= 0)
|
|
||||||
{
|
|
||||||
$this->logTask('Auto-close disabled (days = 0)');
|
|
||||||
return Status::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = Factory::getDbo();
|
|
||||||
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
|
|
||||||
|
|
||||||
$db->setQuery(
|
|
||||||
"UPDATE {$db->quoteName('#__mokosuiteclient_tickets')}"
|
|
||||||
. " SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}"
|
|
||||||
. " WHERE status = 'resolved'"
|
|
||||||
. " AND resolved IS NOT NULL"
|
|
||||||
. " AND resolved < {$db->quote($cutoff)}"
|
|
||||||
);
|
|
||||||
$db->execute();
|
|
||||||
$closed = $db->getAffectedRows();
|
|
||||||
|
|
||||||
$this->logTask("Auto-close: {$closed} tickets closed (resolved > {$days} days ago)");
|
|
||||||
return Status::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private function getComponentConfig(): array
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
$db = Factory::getDbo();
|
|
||||||
$db->setQuery(
|
|
||||||
$db->getQuery(true)
|
|
||||||
->select('params')
|
|
||||||
->from('#__extensions')
|
|
||||||
->where('element = ' . $db->quote('com_mokosuiteclient'))
|
|
||||||
->where('type = ' . $db->quote('component'))
|
|
||||||
);
|
|
||||||
return json_decode($db->loadResult() ?? '{}', true) ?: [];
|
|
||||||
}
|
|
||||||
catch (\Throwable $e)
|
|
||||||
{
|
|
||||||
Log::add('Failed to load component config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findUserByEmail(string $email): int
|
|
||||||
{
|
|
||||||
$db = Factory::getDbo();
|
|
||||||
$db->setQuery(
|
|
||||||
$db->getQuery(true)
|
|
||||||
->select('id')
|
|
||||||
->from('#__users')
|
|
||||||
->where('email = ' . $db->quote($email))
|
|
||||||
->setLimit(1)
|
|
||||||
);
|
|
||||||
return (int) $db->loadResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getImapBody($mbox, int $msgNum): string
|
|
||||||
{
|
|
||||||
$structure = imap_fetchstructure($mbox, $msgNum);
|
|
||||||
|
|
||||||
// Simple single-part message
|
|
||||||
if (empty($structure->parts))
|
|
||||||
{
|
|
||||||
$body = imap_fetchbody($mbox, $msgNum, '1');
|
|
||||||
if ($structure->encoding === 3) $body = base64_decode($body);
|
|
||||||
if ($structure->encoding === 4) $body = quoted_printable_decode($body);
|
|
||||||
return trim(strip_tags($body));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multipart — find text/plain or text/html
|
|
||||||
$textBody = '';
|
|
||||||
|
|
||||||
foreach ($structure->parts as $i => $part)
|
|
||||||
{
|
|
||||||
$partNum = (string) ($i + 1);
|
|
||||||
|
|
||||||
if ($part->type === 0) // text
|
|
||||||
{
|
|
||||||
$content = imap_fetchbody($mbox, $msgNum, $partNum);
|
|
||||||
if ($part->encoding === 3) $content = base64_decode($content);
|
|
||||||
if ($part->encoding === 4) $content = quoted_printable_decode($content);
|
|
||||||
|
|
||||||
$subtype = strtolower($part->subtype ?? '');
|
|
||||||
|
|
||||||
if ($subtype === 'plain' && empty($textBody))
|
|
||||||
{
|
|
||||||
$textBody = $content;
|
|
||||||
}
|
|
||||||
elseif ($subtype === 'html' && empty($textBody))
|
|
||||||
{
|
|
||||||
$textBody = strip_tags($content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim($textBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Daily license revalidation — refresh cached license status from MokoGitea.
|
|
||||||
* Recommended schedule: daily at 3:00 AM.
|
|
||||||
*/
|
|
||||||
private function runLicenseValidation(ExecuteTaskEvent $event): int
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$result = \Moko\Plugin\System\MokoSuiteClient\Helper\LicenseValidator::validate(true);
|
|
||||||
|
|
||||||
$status = $result->valid ? 'valid' : ($result->status ?? 'invalid');
|
|
||||||
$tier = $result->tier ?? 'none';
|
|
||||||
$entitlements = count($result->entitlements ?? []);
|
|
||||||
|
|
||||||
$this->logTask(sprintf(
|
|
||||||
'License validation: status=%s tier=%s entitlements=%d',
|
|
||||||
$status, $tier, $entitlements
|
|
||||||
));
|
|
||||||
|
|
||||||
if (!$result->valid) {
|
|
||||||
Log::add('License validation failed: ' . ($result->message ?? 'unknown'), Log::WARNING, 'mokosuite.license');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Status::OK;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logTask('License validation error: ' . $e->getMessage());
|
|
||||||
return Status::KNOCKOUT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Daily heartbeat — report active installation to MokoGitea.
|
|
||||||
* Recommended schedule: daily at 4:00 AM.
|
|
||||||
*/
|
|
||||||
private function runLicenseHeartbeat(ExecuteTaskEvent $event): int
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$result = \Moko\Plugin\System\MokoSuiteClient\Helper\LicenseValidator::heartbeat();
|
|
||||||
|
|
||||||
$this->logTask('License heartbeat: ' . ($result->success ?? false ? 'sent' : ($result->error ?? 'failed')));
|
|
||||||
return Status::OK;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logTask('License heartbeat error: ' . $e->getMessage());
|
|
||||||
return Status::KNOCKOUT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>PLG_TASK_MOKOSUITECLIENTDEMO_DESC</description>
|
<description>PLG_TASK_MOKOSUITECLIENTDEMO_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
|
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: MokoSuiteClient
|
* INGROUP: MokoSuiteClient
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php
|
* PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php
|
||||||
* VERSION: 02.46.80
|
* VERSION: 02.47.77
|
||||||
* BRIEF: Content-only snapshot/restore for demo site reset
|
* 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>
|
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>PLG_TASK_MOKOSUITECLIENTSYNC_DESC</description>
|
<description>PLG_TASK_MOKOSUITECLIENTSYNC_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace>
|
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: MokoSuiteClient
|
* INGROUP: MokoSuiteClient
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php
|
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php
|
||||||
* VERSION: 02.46.80
|
* VERSION: 02.47.77
|
||||||
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
|
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: MokoSuiteClient
|
* INGROUP: MokoSuiteClient
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php
|
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php
|
||||||
* VERSION: 02.46.80
|
* VERSION: 02.47.77
|
||||||
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<description>Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info.</description>
|
<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>
|
<namespace path="src">Moko\Plugin\WebServices\MokoSuiteClient</namespace>
|
||||||
<files>
|
<files>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<extension type="package" method="upgrade">
|
<extension type="package" method="upgrade">
|
||||||
<name>Package - MokoSuiteClient</name>
|
<name>Package - MokoSuiteClient</name>
|
||||||
<packagename>mokosuiteclient</packagename>
|
<packagename>mokosuiteclient</packagename>
|
||||||
<version>02.46.80</version>
|
<version>02.47.77</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
@@ -28,7 +28,6 @@
|
|||||||
<file type="plugin" id="plg_webservices_mokosuiteclient" group="webservices">plg_webservices_mokosuiteclient.zip</file>
|
<file type="plugin" id="plg_webservices_mokosuiteclient" group="webservices">plg_webservices_mokosuiteclient.zip</file>
|
||||||
<file type="plugin" id="plg_task_mokosuiteclientdemo" group="task">plg_task_mokosuiteclientdemo.zip</file>
|
<file type="plugin" id="plg_task_mokosuiteclientdemo" group="task">plg_task_mokosuiteclientdemo.zip</file>
|
||||||
<file type="plugin" id="plg_task_mokosuiteclientsync" group="task">plg_task_mokosuiteclientsync.zip</file>
|
<file type="plugin" id="plg_task_mokosuiteclientsync" group="task">plg_task_mokosuiteclientsync.zip</file>
|
||||||
<file type="plugin" id="plg_task_mokosuiteclient_tickets" group="task">plg_task_mokosuiteclient_tickets.zip</file>
|
|
||||||
</files>
|
</files>
|
||||||
|
|
||||||
<updateservers>
|
<updateservers>
|
||||||
|
|||||||
+294
-141
@@ -22,16 +22,6 @@ use Joomla\CMS\Log\Log;
|
|||||||
*/
|
*/
|
||||||
class Pkg_MokosuiteclientInstallerScript
|
class Pkg_MokosuiteclientInstallerScript
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Runs after package installation/update.
|
|
||||||
*
|
|
||||||
* @param string $type Installation type
|
|
||||||
* @param InstallerAdapter $parent Parent installer
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 2.2.0
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Runs before package installation/update.
|
* Runs before package installation/update.
|
||||||
*
|
*
|
||||||
@@ -92,7 +82,10 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
$this->enablePlugin('webservices', 'mokosuiteclient');
|
$this->enablePlugin('webservices', 'mokosuiteclient');
|
||||||
$this->enablePlugin('task', 'mokosuiteclientdemo');
|
$this->enablePlugin('task', 'mokosuiteclientdemo');
|
||||||
$this->enablePlugin('task', 'mokosuiteclientsync');
|
$this->enablePlugin('task', 'mokosuiteclientsync');
|
||||||
$this->enablePlugin('task', 'mokosuiteclient_tickets');
|
|
||||||
|
// Ensure Joomla action log plugins are enabled (required for login tracking)
|
||||||
|
$this->enablePlugin('actionlog', 'joomla');
|
||||||
|
$this->enablePlugin('system', 'actionlogs');
|
||||||
|
|
||||||
// Migrate params from core plugin to feature plugins (one-time)
|
// Migrate params from core plugin to feature plugins (one-time)
|
||||||
$this->migrateFeatureParams();
|
$this->migrateFeatureParams();
|
||||||
@@ -115,6 +108,9 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
// Set up MokoSuiteClient guided tours and unpublish Joomla defaults
|
// Set up MokoSuiteClient guided tours and unpublish Joomla defaults
|
||||||
$this->setupGuidedTours();
|
$this->setupGuidedTours();
|
||||||
|
|
||||||
|
// Register MokoSuiteClient guided tour content (tours + steps)
|
||||||
|
$this->registerGuidedTours();
|
||||||
|
|
||||||
// Clean up orphaned empty-element rows and stale files from old DEFAULT '' bug
|
// Clean up orphaned empty-element rows and stale files from old DEFAULT '' bug
|
||||||
$this->cleanupEmptyElements();
|
$this->cleanupEmptyElements();
|
||||||
|
|
||||||
@@ -521,47 +517,8 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||||
);
|
);
|
||||||
|
|
||||||
if ((int) $db->loadResult() > 0)
|
// No row exists — reinstallBrokenPlugins() will handle it via
|
||||||
{
|
// Joomla's Installer which properly registers the namespace
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No row exists — create one from the manifest XML on disk
|
|
||||||
$manifestFile = $pluginDir . '/' . $element . '.xml';
|
|
||||||
|
|
||||||
if (is_file($manifestFile))
|
|
||||||
{
|
|
||||||
$xml = @simplexml_load_file($manifestFile);
|
|
||||||
$name = $xml ? (string) ($xml->name ?? $manifestName) : $manifestName;
|
|
||||||
$namespace = $xml ? (string) ($xml->namespace ?? '') : '';
|
|
||||||
|
|
||||||
$row = (object) [
|
|
||||||
'name' => $name,
|
|
||||||
'type' => 'plugin',
|
|
||||||
'element' => $element,
|
|
||||||
'folder' => $group,
|
|
||||||
'client_id' => 0,
|
|
||||||
'enabled' => 1,
|
|
||||||
'access' => 1,
|
|
||||||
'protected' => 0,
|
|
||||||
'locked' => 0,
|
|
||||||
'params' => '{}',
|
|
||||||
'manifest_cache' => '{}',
|
|
||||||
'custom_data' => '',
|
|
||||||
'state' => 0,
|
|
||||||
'ordering' => 0,
|
|
||||||
'checked_out' => null,
|
|
||||||
'checked_out_time' => null,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!empty($namespace))
|
|
||||||
{
|
|
||||||
$row->namespace = $namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db->insertObject('#__extensions', $row, 'extension_id');
|
|
||||||
Log::add('Created extension record for plugin ' . $group . '/' . $element, Log::INFO, 'mokosuiteclient');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (\Throwable $e)
|
catch (\Throwable $e)
|
||||||
{
|
{
|
||||||
@@ -680,8 +637,8 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$installer = $this->installerParent->getParent();
|
$parentInstaller = $this->installerParent->getParent();
|
||||||
$sourceDir = $installer->getPath('source');
|
$sourceDir = $parentInstaller->getPath('source');
|
||||||
|
|
||||||
if (empty($sourceDir) || !is_dir($sourceDir . '/packages'))
|
if (empty($sourceDir) || !is_dir($sourceDir . '/packages'))
|
||||||
{
|
{
|
||||||
@@ -691,7 +648,7 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
// Plugins that should exist on disk
|
// Plugins that should exist on disk
|
||||||
$expected = [
|
$expected = [
|
||||||
'system' => ['mokosuiteclient_offline', 'mokosuiteclient_firewall', 'mokosuiteclient_tenant', 'mokosuiteclient_devtools', 'mokosuiteclient_dbip'],
|
'system' => ['mokosuiteclient_offline', 'mokosuiteclient_firewall', 'mokosuiteclient_tenant', 'mokosuiteclient_devtools', 'mokosuiteclient_dbip'],
|
||||||
'task' => ['mokosuiteclient_tickets'],
|
'task' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($expected as $group => $elements)
|
foreach ($expected as $group => $elements)
|
||||||
@@ -699,13 +656,7 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
foreach ($elements as $element)
|
foreach ($elements as $element)
|
||||||
{
|
{
|
||||||
$pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element;
|
$pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element;
|
||||||
|
$zipName = 'plg_' . $group . '_' . $element . '.zip';
|
||||||
if (is_dir($pluginDir))
|
|
||||||
{
|
|
||||||
continue; // Already installed correctly
|
|
||||||
}
|
|
||||||
|
|
||||||
$zipName = 'plg_' . $group . '_' . $element . '.zip';
|
|
||||||
$zipPath = $sourceDir . '/packages/' . $zipName;
|
$zipPath = $sourceDir . '/packages/' . $zipName;
|
||||||
|
|
||||||
if (!is_file($zipPath))
|
if (!is_file($zipPath))
|
||||||
@@ -713,7 +664,7 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the zip to the correct plugin directory
|
// Extract to plugin dir
|
||||||
$zip = new \ZipArchive();
|
$zip = new \ZipArchive();
|
||||||
|
|
||||||
if ($zip->open($zipPath) !== true)
|
if ($zip->open($zipPath) !== true)
|
||||||
@@ -721,11 +672,101 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_dir($pluginDir))
|
||||||
|
{
|
||||||
|
$this->rmdirRecursive($pluginDir);
|
||||||
|
}
|
||||||
|
|
||||||
@mkdir($pluginDir, 0755, true);
|
@mkdir($pluginDir, 0755, true);
|
||||||
$zip->extractTo($pluginDir);
|
$zip->extractTo($pluginDir);
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Log::add("Reinstalled {$group}/{$element} from package zip", Log::INFO, 'mokosuiteclient');
|
// Step 2: Create DB records for plugins that have files but no record
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
foreach ($expected as $group => $elements)
|
||||||
|
{
|
||||||
|
foreach ($elements as $element)
|
||||||
|
{
|
||||||
|
$pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element;
|
||||||
|
$manifestFile = $pluginDir . '/' . $element . '.xml';
|
||||||
|
|
||||||
|
if (!is_file($manifestFile))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if record exists
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__extensions'))
|
||||||
|
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||||
|
->where($db->quoteName('folder') . ' = ' . $db->quote($group))
|
||||||
|
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((int) $db->loadResult() > 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse manifest for name and namespace
|
||||||
|
$xml = @simplexml_load_file($manifestFile);
|
||||||
|
$name = $xml ? (string) ($xml->name ?? '') : '';
|
||||||
|
$namespace = $xml ? (string) ($xml->namespace ?? '') : '';
|
||||||
|
$version = $xml ? (string) ($xml->version ?? '') : '';
|
||||||
|
|
||||||
|
if (empty($name))
|
||||||
|
{
|
||||||
|
$name = 'plg_' . $group . '_' . $element;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build manifest cache
|
||||||
|
$cache = json_encode([
|
||||||
|
'name' => $name,
|
||||||
|
'type' => 'plugin',
|
||||||
|
'version' => $version,
|
||||||
|
'group' => $group,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// INSERT with all required fields including namespace
|
||||||
|
$columns = 'name, type, element, folder, client_id, enabled, access, protected, locked, params, manifest_cache, custom_data, state, ordering';
|
||||||
|
$values = $db->quote($name) . ', '
|
||||||
|
. $db->quote('plugin') . ', '
|
||||||
|
. $db->quote($element) . ', '
|
||||||
|
. $db->quote($group) . ', '
|
||||||
|
. '0, 1, 1, 0, 0, '
|
||||||
|
. $db->quote('{}') . ', '
|
||||||
|
. $db->quote($cache) . ', '
|
||||||
|
. $db->quote('') . ', 0, 0';
|
||||||
|
|
||||||
|
$sql = "INSERT INTO " . $db->quoteName('#__extensions')
|
||||||
|
. " ({$columns}) VALUES ({$values})";
|
||||||
|
$db->setQuery($sql)->execute();
|
||||||
|
$newId = $db->insertid();
|
||||||
|
|
||||||
|
// Set namespace if column exists
|
||||||
|
if (!empty($namespace))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->update($db->quoteName('#__extensions'))
|
||||||
|
->set($db->quoteName('namespace') . ' = ' . $db->quote($namespace))
|
||||||
|
->where($db->quoteName('extension_id') . ' = ' . (int) $newId)
|
||||||
|
)->execute();
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
// namespace column may not exist in this Joomla version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::add("Created extension record for {$group}/{$element} (ID {$newId})", Log::INFO, 'mokosuiteclient');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -754,7 +795,6 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
$db->quote('mod_mokosuiteclient_cpanel'),
|
$db->quote('mod_mokosuiteclient_cpanel'),
|
||||||
$db->quote('mokosuiteclientdemo'),
|
$db->quote('mokosuiteclientdemo'),
|
||||||
$db->quote('mokosuiteclientsync'),
|
$db->quote('mokosuiteclientsync'),
|
||||||
$db->quote('mokosuiteclient_tickets'),
|
|
||||||
$db->quote('mokoonyx'),
|
$db->quote('mokoonyx'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1449,108 +1489,217 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
);
|
);
|
||||||
$db->execute();
|
$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 = [
|
$tours = [
|
||||||
[
|
[
|
||||||
'uid' => 'mokosuiteclient-welcome',
|
'uid' => 'com_mokosuiteclient.welcome',
|
||||||
'title' => 'Welcome to MokoSuiteClient',
|
'title' => 'MokoSuite Welcome',
|
||||||
'desc' => 'Get started with the MokoSuiteClient Admin Tools Suite. This tour shows you the key areas of your admin dashboard.',
|
'description' => 'Get started with MokoSuite — configure your health token, send your first heartbeat, and set up trusted IPs.',
|
||||||
'url' => 'administrator/index.php?option=com_mokosuiteclient',
|
'extensions' => '["com_mokosuiteclient"]',
|
||||||
'steps' => [
|
'url' => 'administrator/index.php?option=com_mokosuiteclient',
|
||||||
['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],
|
'steps' => [
|
||||||
['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' => 'Welcome to MokoSuite',
|
||||||
['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],
|
'description' => 'This is your MokoSuite control panel. Let\'s walk through the key features.',
|
||||||
['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],
|
'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',
|
'uid' => 'com_mokosuiteclient.firewall',
|
||||||
'title' => 'MokoSuiteClient Firewall Setup',
|
'title' => 'MokoSuite Firewall Setup',
|
||||||
'desc' => 'Configure the Web Application Firewall to protect your site from common attacks.',
|
'description' => 'Configure your Web Application Firewall — trusted IPs, WAF shields, and security headers.',
|
||||||
'url' => 'administrator/index.php?option=com_plugins&task=plugin.edit&filter[search]=mokosuiteclient_firewall',
|
'extensions' => '["com_mokosuiteclient"]',
|
||||||
'steps' => [
|
'url' => 'administrator/index.php?option=com_plugins&task=plugin.edit&extension_id=0',
|
||||||
['title' => 'Firewall Plugin', 'desc' => 'The MokoSuiteClient Firewall provides 10 security shields including SQL injection, XSS, and malicious user agent detection.', 'target' => '', 'type' => 0],
|
'steps' => [
|
||||||
['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' => 'Your Current IP',
|
||||||
['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],
|
'description' => 'This shows your IP address. Copy it to add to the Trusted IPs list.',
|
||||||
],
|
'target' => '#mokosuiteclient-current-ip',
|
||||||
],
|
'type' => 2,
|
||||||
[
|
'position' => 'bottom',
|
||||||
'uid' => 'mokosuiteclient-helpdesk',
|
],
|
||||||
'title' => 'MokoSuiteClient Helpdesk',
|
[
|
||||||
'desc' => 'Learn how to manage support tickets, categories, and automation rules.',
|
'title' => 'Trusted IPs',
|
||||||
'url' => 'administrator/index.php?option=com_mokosuiteclient&view=tickets',
|
'description' => 'Add IPs that should bypass WAF checks — your office, VPN, etc.',
|
||||||
'steps' => [
|
'target' => '#jform_params_trusted_ips',
|
||||||
['title' => 'Ticket List', 'desc' => 'View all support tickets with status, priority, SLA tracking, and assignment. Filter by status or search to find specific tickets.', 'target' => '', 'type' => 0],
|
'type' => 2,
|
||||||
['title' => 'Create a Ticket', 'desc' => 'Click the New button to create a support ticket. Assign a category, priority, and optional SLA deadline.', 'target' => '', 'type' => 0],
|
'position' => 'top',
|
||||||
['title' => 'Ticket Automation', 'desc' => 'Set up automation rules that trigger on ticket events (new ticket, status change) or Joomla events (user login, registration). Automate assignment, notifications, and status changes.', 'target' => '', 'type' => 0],
|
],
|
||||||
],
|
[
|
||||||
],
|
'title' => 'WAF Shields',
|
||||||
[
|
'description' => 'Enable protection against SQL injection, XSS, malicious agents, and more.',
|
||||||
'uid' => 'mokosuiteclient-extensions',
|
'target' => '#attrib-waf',
|
||||||
'title' => 'Moko Extensions Manager',
|
'type' => 2,
|
||||||
'desc' => 'Browse and install Moko Consulting extensions from the built-in catalog.',
|
'position' => 'bottom',
|
||||||
'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],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($tours as $tourDef)
|
foreach ($tours as $tourDef)
|
||||||
{
|
{
|
||||||
// Check if tour already exists
|
// Check if tour already exists by uid
|
||||||
$db->setQuery(
|
$query = $db->getQuery(true)
|
||||||
$db->getQuery(true)
|
->select($db->quoteName('id'))
|
||||||
->select('id')
|
->from($db->quoteName('#__guidedtours'))
|
||||||
->from($db->quoteName('#__guidedtours'))
|
->where($db->quoteName('uid') . ' = ' . $db->quote($tourDef['uid']));
|
||||||
->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) [
|
// Insert steps
|
||||||
'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;
|
|
||||||
|
|
||||||
foreach ($tourDef['steps'] as $i => $stepDef)
|
foreach ($tourDef['steps'] as $i => $stepDef)
|
||||||
{
|
{
|
||||||
$step = (object) [
|
$step = (object) [
|
||||||
'tour_id' => $tourId,
|
'tour_id' => $tourId,
|
||||||
'title' => $stepDef['title'],
|
'title' => $stepDef['title'],
|
||||||
'description' => $stepDef['desc'],
|
'description' => $stepDef['description'],
|
||||||
'target' => $stepDef['target'],
|
'target' => $stepDef['target'],
|
||||||
'type' => $stepDef['type'],
|
'type' => $stepDef['type'],
|
||||||
'interactive_type' => 1,
|
'interactive_type' => 1,
|
||||||
'url' => '',
|
'url' => '',
|
||||||
'position' => 'bottom',
|
'position' => $stepDef['position'],
|
||||||
'ordering' => $i + 1,
|
'ordering' => $i + 1,
|
||||||
'published' => 1,
|
'published' => 1,
|
||||||
'created' => date('Y-m-d H:i:s'),
|
'created' => $now,
|
||||||
'created_by' => 0,
|
'created_by' => 0,
|
||||||
'modified' => date('Y-m-d H:i:s'),
|
'modified' => $now,
|
||||||
'modified_by' => 0,
|
'modified_by' => 0,
|
||||||
'language' => '*',
|
'language' => '*',
|
||||||
'note' => '',
|
'note' => '',
|
||||||
@@ -1560,10 +1709,12 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
$db->insertObject('#__guidedtour_steps', $step, 'id');
|
$db->insertObject('#__guidedtour_steps', $step, 'id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log::add('Registered ' . \count($tours) . ' MokoSuiteClient guided tours.', Log::INFO, 'mokosuiteclient');
|
||||||
}
|
}
|
||||||
catch (\Throwable $e)
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1572,6 +1723,8 @@ class Pkg_MokosuiteclientInstallerScript
|
|||||||
*/
|
*/
|
||||||
private function setupSupportMenuItem(): void
|
private function setupSupportMenuItem(): void
|
||||||
{
|
{
|
||||||
|
// Tickets moved to MokoSuiteCRM — no frontend support menu needed
|
||||||
|
return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|||||||
Reference in New Issue
Block a user