Merge pull request 'Release: dev to main - UI cleanup, RL import, menu icons' (#269) from merge/dev-to-main-268 into main

This commit was merged in pull request #269.
This commit is contained in:
2026-06-28 18:29:17 +00:00
91 changed files with 3686 additions and 3706 deletions
+1 -2
View File
@@ -38,7 +38,7 @@ Joomla **package** (`pkg_mokosuiteclient`) with 17 sub-extensions:
### Component (`com_mokosuiteclient`)
- Admin dashboard with plugin management, WAF charts, extension catalog
- Helpdesk ticketing system
- Content tools: snippets, templates, replacements, conditions, articles anywhere, users anywhere
- REST API controllers
### Modules
@@ -50,7 +50,6 @@ Joomla **package** (`pkg_mokosuiteclient`) with 17 sub-extensions:
### Task Plugins
- `plg_task_mokosuiteclientdemo` — scheduled demo site reset
- `plg_task_mokosuiteclientsync` — scheduled content sync
- `plg_task_mokosuiteclient_tickets` — ticket automation
### Update Server
-439
View File
@@ -1,439 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/ci-platform.yml
# VERSION: 09.23.00
# BRIEF: moko-platform CI — the standards engine validates itself
#
# +========================================================================+
# | MOKO-PLATFORM CI |
# +========================================================================+
# | |
# | This is NOT a generic CI workflow. This is the self-validation |
# | pipeline for the central moko-platform enterprise engine. |
# | |
# | It dogfoods every tool the platform ships to governed repos: |
# | |
# | Gate 1 — Code Quality phpcs (PSR-12), phpstan (L5), psalm |
# | Gate 2 — Unit Tests phpunit with coverage threshold |
# | Gate 3 — Self-Health bin/moko health against its own repo |
# | Gate 4 — Governance Checks headers, secrets, structure, versions |
# | Gate 5 — Template Lint validate workflow templates parse clean |
# | |
# | If it doesn't pass its own checks, it can't enforce them. |
# | |
# +========================================================================+
name: "Platform: moko-platform CI"
on:
push:
branches:
- main
- dev
- dev/**
- rc/**
paths-ignore:
- '**.md'
- 'wiki/**'
- '.gitea/ISSUE_TEMPLATE/**'
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch:
inputs:
full_suite:
description: 'Run full validation suite (including slow checks)'
required: false
default: 'true'
type: boolean
concurrency:
group: ci-platform-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
PHP_VERSION: '8.2'
jobs:
# ═══════════════════════════════════════════════════════════════════════
# Gate 1 — Code Quality
# ═══════════════════════════════════════════════════════════════════════
code-quality:
name: "Gate 1: Code Quality"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP ${{ env.PHP_VERSION }}
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
php${{ env.PHP_VERSION }}-intl composer >/dev/null 2>&1
php -v
- name: Install Composer dependencies
run: |
composer install --no-interaction --prefer-dist
echo "Dependencies installed: $(composer show | wc -l) packages"
- name: "PHP Syntax Check"
run: |
ERRORS=0
CHECKED=0
while IFS= read -r -d '' file; do
CHECKED=$((CHECKED + 1))
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find lib/ validate/ automation/ cli/ src/ deploy/ -name "*.php" -print0 2>/dev/null)
{
echo "### PHP Syntax"
echo "Checked ${CHECKED} files — ${ERRORS} error(s)"
} >> $GITHUB_STEP_SUMMARY
[ "$ERRORS" -eq 0 ] || exit 1
- name: "PHPCS (PSR-12)"
run: |
vendor/bin/phpcs --standard=phpcs.xml --report=summary --warning-severity=0 lib/ validate/ automation/ 2>&1 || {
echo "::error::PHPCS found coding standard violations"
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
echo "Coding standard violations detected. Run \`composer phpcs\` locally." >> $GITHUB_STEP_SUMMARY
exit 1
}
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
echo "PSR-12 compliance: passed" >> $GITHUB_STEP_SUMMARY
- name: "PHPStan (Level 6)"
run: |
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --memory-limit=512M --error-format=github 2>&1 || {
echo "::error::PHPStan found type errors"
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
echo "Static analysis errors detected. Run \`composer phpstan\` locally." >> $GITHUB_STEP_SUMMARY
exit 1
}
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
echo "Static analysis (level 6): passed" >> $GITHUB_STEP_SUMMARY
- name: "Psalm"
continue-on-error: true
run: |
if [ -f "psalm.xml" ]; then
vendor/bin/psalm --config=psalm.xml --no-progress --output-format=github 2>&1 || {
echo "### Psalm" >> $GITHUB_STEP_SUMMARY
echo "Psalm found issues (advisory — not blocking)." >> $GITHUB_STEP_SUMMARY
}
fi
# ═══════════════════════════════════════════════════════════════════════
# Gate 2 — Unit Tests
# ═══════════════════════════════════════════════════════════════════════
tests:
name: "Gate 2: Unit Tests"
runs-on: ubuntu-latest
timeout-minutes: 15
needs: code-quality
strategy:
matrix:
php: ['8.1', '8.2', '8.3']
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ matrix.php }}-cli php${{ matrix.php }}-mbstring \
php${{ matrix.php }}-xml php${{ matrix.php }}-curl php${{ matrix.php }}-zip \
php${{ matrix.php }}-intl composer >/dev/null 2>&1
php -v
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: "PHPUnit (PHP ${{ matrix.php }})"
run: |
vendor/bin/phpunit --testdox 2>&1 || {
echo "::error::PHPUnit tests failed"
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
echo "Tests failed. Run \`vendor/bin/phpunit --testdox\` locally." >> $GITHUB_STEP_SUMMARY
exit 1
}
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
# ═══════════════════════════════════════════════════════════════════════
# Gate 3 — Self-Health (Dogfood)
# ═══════════════════════════════════════════════════════════════════════
self-health:
name: "Gate 3: Self-Health Check"
runs-on: ubuntu-latest
timeout-minutes: 10
needs: code-quality
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
composer >/dev/null 2>&1
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: "Run bin/moko health against self"
run: |
php bin/moko health -- --path . --json > /tmp/health-report.json 2>&1 || true
SCORE=$(cat /tmp/health-report.json | python3 -c "import sys,json; print(json.load(sys.stdin).get('percentage', 0))" 2>/dev/null || echo "0")
LEVEL=$(cat /tmp/health-report.json | python3 -c "import sys,json; print(json.load(sys.stdin).get('level', 'unknown'))" 2>/dev/null || echo "unknown")
{
echo "### Self-Health Report"
echo ""
echo "| Metric | Value |"
echo "|---|---|"
echo "| Score | ${SCORE}% |"
echo "| Level | ${LEVEL} |"
echo ""
echo "The platform must pass its own health check to enforce it on others."
} >> $GITHUB_STEP_SUMMARY
# Platform must score at least 80%
python3 -c "exit(0 if float('${SCORE}') >= 80.0 else 1)" || {
echo "::error::Self-health score ${SCORE}% is below 80% threshold"
exit 1
}
# ═══════════════════════════════════════════════════════════════════════
# Gate 4 — Governance Checks
# ═══════════════════════════════════════════════════════════════════════
governance:
name: "Gate 4: Governance"
runs-on: ubuntu-latest
timeout-minutes: 10
needs: code-quality
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl composer >/dev/null 2>&1
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: "License headers (SPDX)"
run: |
MISSING=0
CHECKED=0
while IFS= read -r -d '' file; do
CHECKED=$((CHECKED + 1))
if ! head -n 20 "$file" | grep -q "SPDX-License-Identifier:"; then
echo "::warning file=${file}::Missing SPDX header"
MISSING=$((MISSING + 1))
fi
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
{
echo "### License Headers"
echo "Checked ${CHECKED} files — ${MISSING} missing SPDX headers"
} >> $GITHUB_STEP_SUMMARY
# Advisory — warn but don't fail (yet)
[ "$MISSING" -eq 0 ] || echo "::warning::${MISSING} files missing SPDX license headers"
- name: "Secret detection"
run: |
FOUND=0
# Check for common secret patterns in source files
while IFS= read -r -d '' file; do
if grep -qEi '(password|secret|token|apikey|api_key)\s*[:=]\s*["\x27][^\s]{8,}' "$file" 2>/dev/null; then
echo "::error file=${file}::Potential hardcoded secret detected"
FOUND=$((FOUND + 1))
fi
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
{
echo "### Secret Detection"
if [ "$FOUND" -eq 0 ]; then
echo "No hardcoded secrets detected."
else
echo "${FOUND} potential secrets found."
fi
} >> $GITHUB_STEP_SUMMARY
[ "$FOUND" -eq 0 ] || exit 1
- name: "Version consistency"
run: |
# Extract version from composer.json
COMPOSER_VER=$(python3 -c "import json; print(json.load(open('composer.json'))['version'])")
# Extract version from README.md
README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
{
echo "### Version Consistency"
echo "| Source | Version |"
echo "|---|---|"
echo "| composer.json | ${COMPOSER_VER} |"
echo "| README.md | ${README_VER:-not found} |"
} >> $GITHUB_STEP_SUMMARY
if [ -n "$README_VER" ] && [ "$COMPOSER_VER" != "$README_VER" ]; then
echo "::warning::Version mismatch: composer.json=${COMPOSER_VER} vs README.md=${README_VER}"
fi
# ═══════════════════════════════════════════════════════════════════════
# Gate 5 — Template Integrity
# ═══════════════════════════════════════════════════════════════════════
templates:
name: "Gate 5: Template Integrity"
runs-on: ubuntu-latest
timeout-minutes: 10
needs: code-quality
if: github.event_name != 'push' || github.event.inputs.full_suite != 'false'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: "Validate workflow templates"
run: |
ERRORS=0
CHECKED=0
# Check all YAML workflow templates parse cleanly
while IFS= read -r -d '' file; do
CHECKED=$((CHECKED + 1))
if ! python3 -c "import yaml; yaml.safe_load(open('${file}'))" 2>/dev/null; then
echo "::error file=${file}::Invalid YAML"
ERRORS=$((ERRORS + 1))
fi
done < <(find templates/workflows/ -name "*.yml" -o -name "*.yaml" 2>/dev/null | tr '\n' '\0')
# Also check the live workflows
while IFS= read -r -d '' file; do
CHECKED=$((CHECKED + 1))
if ! python3 -c "import yaml; yaml.safe_load(open('${file}'))" 2>/dev/null; then
echo "::error file=${file}::Invalid YAML"
ERRORS=$((ERRORS + 1))
fi
done < <(find .mokogitea/workflows/ -name "*.yml" -o -name "*.yaml" 2>/dev/null | tr '\n' '\0')
{
echo "### Template Integrity"
echo "Validated ${CHECKED} YAML files — ${ERRORS} parse errors"
} >> $GITHUB_STEP_SUMMARY
[ "$ERRORS" -eq 0 ] || exit 1
- name: "Validate gitignore templates"
run: |
TEMPLATES=0
for GI in templates/configs/gitignore templates/configs/gitignore.dolibarr templates/configs/.gitignore.joomla; do
if [ -f "$GI" ]; then
TEMPLATES=$((TEMPLATES + 1))
# Verify required entries
for REQUIRED in ".claude/" "TODO.md" "*.min.css" "*.min.js" "wiki/"; do
if ! grep -q "$REQUIRED" "$GI"; then
echo "::error file=${GI}::Missing required entry: ${REQUIRED}"
fi
done
fi
done
echo "### Gitignore Templates" >> $GITHUB_STEP_SUMMARY
echo "Validated ${TEMPLATES} gitignore templates." >> $GITHUB_STEP_SUMMARY
- name: "Validate PHP validation scripts"
run: |
ERRORS=0
CHECKED=0
while IFS= read -r -d '' file; do
CHECKED=$((CHECKED + 1))
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::Validation script has syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find validate/ -name "*.php" -print0 2>/dev/null)
{
echo "### Validation Scripts"
echo "Checked ${CHECKED} scripts — ${ERRORS} syntax errors"
} >> $GITHUB_STEP_SUMMARY
[ "$ERRORS" -eq 0 ] || { echo "::error::Validation scripts must be error-free"; exit 1; }
# ═══════════════════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════════════════
summary:
name: "CI Summary"
runs-on: ubuntu-latest
needs: [code-quality, tests, self-health, governance, templates]
if: always()
steps:
- name: Check gate results
run: |
{
echo "# moko-platform CI"
echo ""
echo "| Gate | Job | Status |"
echo "|---|---|---|"
echo "| 1 | Code Quality | ${{ needs.code-quality.result }} |"
echo "| 2 | Unit Tests | ${{ needs.tests.result }} |"
echo "| 3 | Self-Health | ${{ needs.self-health.result }} |"
echo "| 4 | Governance | ${{ needs.governance.result }} |"
echo "| 5 | Templates | ${{ needs.templates.result }} |"
echo ""
echo "> *The standards engine must pass its own standards.*"
} >> $GITHUB_STEP_SUMMARY
# Fail if any required gate failed
if [ "${{ needs.code-quality.result }}" = "failure" ] || \
[ "${{ needs.tests.result }}" = "failure" ] || \
[ "${{ needs.self-health.result }}" = "failure" ] || \
[ "${{ needs.governance.result }}" = "failure" ] || \
[ "${{ needs.templates.result }}" = "failure" ]; then
echo "::error::One or more CI gates failed"
exit 1
fi
+21 -2
View File
@@ -14,7 +14,7 @@
INGROUP: MokoSuiteClient.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
PATH: ./CHANGELOG.md
VERSION: 02.47.48
VERSION: 02.48.52
BRIEF: Version history using `Keep a Changelog`
-->
@@ -24,7 +24,18 @@
### 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
- **Support PIN in status bar** — cache/temp module now shows PIN request button instead of domain; click to request, click again to copy
- **Frontend link in status bar** — cache/temp module now has 4 buttons: Site (frontend link), PIN, Cache, Temp
- **Help buttons** — all admin views link to Gitea wiki pages via toolbar help button
- **Support PIN in heartbeat** — core system plugin includes current PIN in heartbeat payload to HQ
- **HQ config sync** — client stores HQ-configured `support_pin_hours` from heartbeat response, PIN TTL now configurable from HQ
### Changed
- **Support PIN UI unified** — `SupportPinHelper::renderBadge()` and `renderScript()` replace 3 separate inline implementations (dashboard, cpanel module, cache module) with click-to-copy on all PIN badges
- Admin sidebar menu module now loads component-local language files (fixes untranslated keys for MokoSuiteCross and other components)
- Support PIN TTL is now configurable via HQ global options instead of hardcoded 72 hours
- Removed MokoSuiteHQ from extension catalog (internal app, not for client sites)
- **SupportPinHelper** — shared helper centralises PIN generation across dashboard, cpanel module, cache module, and AJAX controller
- **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
@@ -53,6 +64,14 @@
- **Update server migration** — removed migrateUpdateServerUrls, cleanupStaleUpdateSites, fixUpdateRecords, enableUpdateServer calls
### Fixed
- **Regular Labs import** — destination tables missing from SQL update files; sites that upgraded never got the tables, causing "No data found" on import
- **Regular Labs import banner** — detection now requires both source AND destination tables before showing the import button
- **DB-IP auto-enrichment** — all IPs in `<code>` tags in admin backend now show country flag emoji and geo tooltip on hover
- **MokoSuiteBackup quick action** — dashboard now includes MokoSuiteBackup button when component is installed
- **PIN copy** — fixed duplicate click handlers (4 toast messages), "Copied!" not reverting, added "Click to copy" hover tooltip
- Health endpoint cron check SQL error — orphan `setQuery(getQuery(true), 0, 5)` produced bare `LIMIT 5`, returning 503 for all health polls
- License plugin missing `src/` and `language/` directories causing install failure
- PIN generation inconsistency — controller used `floor(now/TTL)` while display used `floor(requestedAt/TTL)`
- 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)
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuiteClient.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
VERSION: 02.47.48
VERSION: 02.48.52
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
+1 -1
View File
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand
-->
+1 -1
View File
@@ -15,7 +15,7 @@
INGROUP: MokoSuiteClient.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
PATH: ./LICENSE.md
VERSION: 02.47.48
VERSION: 02.48.52
BRIEF: Project license (GPL-3.0-or-later)
-->
GNU GENERAL PUBLIC LICENSE
+1 -1
View File
@@ -9,7 +9,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuiteClient
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /README.md
BRIEF: MokoSuiteClient platform plugin for Joomla
-->
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
VERSION: 02.47.48
VERSION: 02.48.52
BRIEF: Security vulnerability reporting and handling policy
-->
+2 -2
View File
@@ -11,13 +11,13 @@
INGROUP: MokoSuiteClient.Build
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
FILE: build-guide.md
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /docs/guides/
BRIEF: Build and packaging guide for the MokoSuiteClient system plugin
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
-->
# MokoSuiteClient Build Guide (VERSION: 02.47.48)
# MokoSuiteClient Build Guide (VERSION: 02.48.52)
## 1. Purpose
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuiteClient.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /docs/guides/configuration-guide.md
BRIEF: Configuration guide for the MokoSuiteClient system plugin
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
-->
# MokoSuiteClient Configuration Guide (VERSION: 02.47.48)
# MokoSuiteClient Configuration Guide (VERSION: 02.48.52)
## 1. Objective
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuiteClient.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /docs/guides/installation-guide.md
BRIEF: Installation guide for the MokoSuiteClient system plugin
NOTE: First document in the guide set
-->
# MokoSuiteClient Installation Guide (VERSION: 02.47.48)
# MokoSuiteClient Installation Guide (VERSION: 02.48.52)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuiteClient.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /docs/guides/operations-guide.md
BRIEF: Operational guide for administering and managing the MokoSuiteClient system plugin
NOTE: Defines lifecycle, responsibilities, and operational behaviors
-->
# MokoSuiteClient Operations Guide (VERSION: 02.47.48)
# MokoSuiteClient Operations Guide (VERSION: 02.48.52)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuiteClient.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /docs/guides/rollback-and-recovery-guide.md
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
NOTE: Completes the core guide set for Suite plugin governance
-->
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.47.48)
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.48.52)
## Introduction
+2 -2
View File
@@ -7,13 +7,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuiteClient.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /docs/guides/testing-guide.md
BRIEF: Testing guide for MokoSuiteClient v02.01.08
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
-->
# MokoSuiteClient Testing Guide (VERSION: 02.47.48)
# MokoSuiteClient Testing Guide (VERSION: 02.48.52)
## 1. Prerequisites
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuiteClient.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /docs/guides/troubleshooting-guide.md
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuiteClient plugin
NOTE: Designed for administrators and Suite operations teams
-->
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.47.48)
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.48.52)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuiteClient.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /docs/guides/upgrade-and-versioning-guide.md
BRIEF: Guide for updating, versioning, and maintaining the MokoSuiteClient plugin
NOTE: Defines release flow, version rules, and upgrade validation
-->
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.47.48)
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.48.52)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuiteClient.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
VERSION: 02.47.48
VERSION: 02.48.52
PATH: /docs/index.md
BRIEF: Master index of all documentation for the MokoSuiteClient plugin
NOTE: Automatically maintained index for all guide canvases
-->
# MokoSuiteClient Documentation Index (VERSION: 02.47.48)
# MokoSuiteClient Documentation Index (VERSION: 02.48.52)
## Introduction
+2 -2
View File
@@ -11,12 +11,12 @@
INGROUP: MokoSuiteClient
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
PATH: /docs/plugin-basic.md
VERSION: 02.47.48
VERSION: 02.48.52
BRIEF: Baseline documentation for the MokoSuiteClient system plugin
NOTE: Foundational reference for internal and external stakeholders
-->
# MokoSuiteClient Plugin Overview (VERSION: 02.47.48)
# MokoSuiteClient Plugin Overview (VERSION: 02.48.52)
## Introduction
+1 -1
View File
@@ -10,7 +10,7 @@ DEFGROUP: MokoSuiteClient.Documentation
INGROUP: MokoStandards.Templates
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient
PATH: /docs/update-server.md
VERSION: 02.47.48
VERSION: 02.48.52
BRIEF: How this extension's Joomla update server file (update.xml) is managed
-->
@@ -1,15 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokosuiteclient">
<section name="component">
<!-- Core Joomla ACL -->
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
<!-- Dashboard & UI -->
<action name="mokosuiteclient.dashboard" title="COM_MOKOSUITECLIENT_ACL_DASHBOARD" description="COM_MOKOSUITECLIENT_ACL_DASHBOARD_DESC" />
<action name="mokosuiteclient.extensions" title="COM_MOKOSUITECLIENT_ACL_EXTENSIONS" description="COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC" />
<action name="mokosuiteclient.htaccess" title="COM_MOKOSUITECLIENT_ACL_HTACCESS" description="COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC" />
<action name="mokosuiteclient.tickets" title="COM_MOKOSUITECLIENT_ACL_TICKETS" description="COM_MOKOSUITECLIENT_ACL_TICKETS_DESC" />
<action name="mokosuiteclient.tickets.create" title="COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE" description="COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE_DESC" />
<action name="mokosuiteclient.tickets.assign" title="COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN" description="COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN_DESC" />
<action name="mokosuiteclient.plugins.toggle" title="COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE" description="COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE_DESC" />
<action name="mokosuiteclient.cache" title="COM_MOKOSUITECLIENT_ACL_CACHE" description="COM_MOKOSUITECLIENT_ACL_CACHE_DESC" />
<!-- Server Config -->
<action name="mokosuiteclient.htaccess" title="COM_MOKOSUITECLIENT_ACL_HTACCESS" description="COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC" />
<!-- Security -->
<action name="mokosuiteclient.security.waflog" title="COM_MOKOSUITECLIENT_ACL_WAFLOG" description="COM_MOKOSUITECLIENT_ACL_WAFLOG_DESC" />
<action name="mokosuiteclient.security.impersonate" title="COM_MOKOSUITECLIENT_ACL_IMPERSONATE" description="COM_MOKOSUITECLIENT_ACL_IMPERSONATE_DESC" />
<!-- Content Tools -->
<action name="mokosuiteclient.snippets.manage" title="COM_MOKOSUITECLIENT_ACL_SNIPPETS" description="COM_MOKOSUITECLIENT_ACL_SNIPPETS_DESC" />
<action name="mokosuiteclient.templates.manage" title="COM_MOKOSUITECLIENT_ACL_TEMPLATES" description="COM_MOKOSUITECLIENT_ACL_TEMPLATES_DESC" />
<action name="mokosuiteclient.replacements.manage" title="COM_MOKOSUITECLIENT_ACL_REPLACEMENTS" description="COM_MOKOSUITECLIENT_ACL_REPLACEMENTS_DESC" />
<action name="mokosuiteclient.conditions.manage" title="COM_MOKOSUITECLIENT_ACL_CONDITIONS" description="COM_MOKOSUITECLIENT_ACL_CONDITIONS_DESC" />
<!-- Extensions & Catalog -->
<action name="mokosuiteclient.extensions" title="COM_MOKOSUITECLIENT_ACL_EXTENSIONS" description="COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC" />
</section>
</access>
@@ -1,122 +1,210 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Extension catalog for MokoSuiteClient Extension Manager.
Each entry points to the extension's own updates.xml. The installer
resolves the latest version and download URL at runtime, respecting
the site's configured update channel (dev/stable).
To add an extension: copy an <extension> block and fill in the fields.
MokoSuite Extension Catalog
Each entry points to the extension's updates.xml on the main branch.
The installer resolves the latest version and download URL at runtime,
respecting the site's configured update channel (stable/dev) from
Joomla's com_installer params.
-->
<catalog>
<!-- ═══════════════════════════════════════════════════════════════════
Platform (Layer 0)
═══════════════════════════════════════════════════════════════════ -->
<extension>
<name>MokoSuiteClient</name>
<element>pkg_mokosuiteclient</element>
<type>package</type>
<description>Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API.</description>
<description>Admin dashboard, security firewall, tenant restrictions, health monitoring, content tools, and REST API.</description>
<icon>icon-shield-alt</icon>
<category>Platform</category>
<article>https://mokoconsulting.tech/support/products/mokosuiteclient-platform</article>
<protected>true</protected>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/dev/updates.xml</updateserver>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteClientHQ</name>
<element>pkg_mokosuiteclienthq</element>
<type>package</type>
<description>Centralized control panel for managing all MokoSuiteClient client installations.</description>
<icon>icon-tachometer-alt</icon>
<category>Platform</category>
<article>https://mokoconsulting.tech/support/products/mokosuiteclient-base</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientHQ/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>MokoOnyx</name>
<element>mokoonyx</element>
<type>template</type>
<description>Modern Joomla site template with dark mode, custom layouts, and MokoSuiteClient integration.</description>
<icon>icon-paint-brush</icon>
<category>Templates</category>
<article>https://mokoconsulting.tech/support/products/mokoonyx-template</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>MokoJoomOpenGraph</name>
<element>pkg_mokoog</element>
<type>package</type>
<description>Open Graph, Twitter Card, and social sharing meta tags for articles, categories, and pages.</description>
<icon>icon-share-alt</icon>
<category>SEO</category>
<article>https://mokoconsulting.tech/support/products/mokojoomopengraph</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteClientBackup</name>
<element>pkg_mokojoombackup</element>
<name>MokoSuiteBackup</name>
<element>pkg_mokosuitebackup</element>
<type>package</type>
<description>Full-site backup and restore for Joomla — database, files, and configuration.</description>
<icon>icon-archive</icon>
<category>Tools</category>
<article>https://mokoconsulting.tech/support/products/mokosuiteclientbackup</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientBackup/raw/branch/dev/updates.xml</updateserver>
<category>Platform</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/raw/branch/main/updates.xml</updateserver>
</extension>
<!-- ═══════════════════════════════════════════════════════════════════
Business Suite (Layers 1-4)
═══════════════════════════════════════════════════════════════════ -->
<extension>
<name>MokoSuiteCRM</name>
<element>pkg_mokosuitecrm</element>
<type>package</type>
<description>Layer 1 — Contacts, deals pipeline, activities, e-signature, email integration, helpdesk.</description>
<icon>icon-address-book</icon>
<category>Business</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteERP</name>
<element>pkg_mokosuiteerp</element>
<type>package</type>
<description>Layer 2 — Products, orders, invoicing, inventory, warehouses, accounting, payments.</description>
<icon>icon-briefcase</icon>
<category>Business</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteERP/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteShop</name>
<element>pkg_mokosuiteshop</element>
<type>package</type>
<description>Layer 3 — Product catalog, shopping cart, checkout, coupons. Requires MokoSuiteERP.</description>
<icon>icon-shopping-cart</icon>
<category>Business</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteShop/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuitePOS</name>
<element>pkg_mokosuitepos</element>
<type>package</type>
<description>Layer 3 — Touch-screen POS, multi-terminal, cash register, receipt printing.</description>
<icon>icon-calculator</icon>
<category>Business</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuitePOS/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteMRP</name>
<element>pkg_mokosuitemrp</element>
<type>package</type>
<description>Layer 3 — BOM, manufacturing orders, workstation management, production scheduling.</description>
<icon>icon-cog</icon>
<category>Business</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteMRP/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteHRM</name>
<element>pkg_mokosuitehrm</element>
<type>package</type>
<description>Layer 3 — Human Resource Management: employees, leave, expenses, payroll, recruiting.</description>
<icon>icon-users</icon>
<category>Business</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteHRM/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteRestaurant</name>
<element>pkg_mokosuiterestaurant</element>
<type>package</type>
<description>Layer 4 — Floor plan, table management, kitchen display, split bills, online ordering.</description>
<icon>icon-utensils</icon>
<category>Industry</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteRestaurant/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteChild</name>
<element>pkg_mokosuitechild</element>
<type>package</type>
<description>Layer 2 — Child Care Management: enrollment, attendance, billing, parent portal.</description>
<icon>icon-child</icon>
<category>Industry</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteChild/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteNPO</name>
<element>pkg_mokosuitenpo</element>
<type>package</type>
<description>Nonprofit management: donors, donations, campaigns, grants, volunteers, events.</description>
<icon>icon-heart</icon>
<category>Industry</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteNPO/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteField</name>
<element>pkg_mokosuitefield</element>
<type>package</type>
<description>Field Service — dispatch, work orders, scheduling, mobile tech, plumbing/HVAC.</description>
<icon>icon-wrench</icon>
<category>Industry</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteCreate</name>
<element>pkg_mokosuitecreate</element>
<type>package</type>
<description>Layer 2 — Creative Agency: projects, tasks, timesheets, client proofing.</description>
<icon>icon-paint-brush</icon>
<category>Industry</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCreate/raw/branch/main/updates.xml</updateserver>
</extension>
<!-- ═══════════════════════════════════════════════════════════════════
Content & Community
═══════════════════════════════════════════════════════════════════ -->
<extension>
<name>MokoSuiteForms</name>
<element>pkg_mokosuiteforms</element>
<type>package</type>
<description>Form builder — custom forms, submissions, notifications, and data exports.</description>
<icon>icon-list-alt</icon>
<category>Content</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteForms/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteCommunity</name>
<element>pkg_mokosuitecommunity</element>
<type>package</type>
<description>Community profiles, connections, and activity streams for Joomla.</description>
<icon>icon-users</icon>
<category>Content</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCommunity/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteCross</name>
<element>pkg_mokosuitecross</element>
<type>package</type>
<description>Cross-posting Joomla content to social media, email marketing, and chat platforms.</description>
<icon>icon-share-alt</icon>
<category>Content</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCross/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteOpenGraph</name>
<element>pkg_mokosuiteopengraph</element>
<type>package</type>
<description>Open Graph, Twitter Card, JSON-LD structured data, and social sharing meta tags.</description>
<icon>icon-share-alt</icon>
<category>SEO</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/raw/branch/main/updates.xml</updateserver>
</extension>
<extension>
<name>MokoSuiteStoreLocator</name>
<element>pkg_mokosuitestorelocator</element>
<type>package</type>
<description>Interactive map, location search, and admin management for store locations.</description>
<icon>icon-map-marker-alt</icon>
<category>Content</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/raw/branch/main/updates.xml</updateserver>
</extension>
<!-- ═══════════════════════════════════════════════════════════════════
Standalone Extensions (MokoJoom)
═══════════════════════════════════════════════════════════════════ -->
<extension>
<name>MokoJoomHero</name>
<element>mod_mokojoomhero</element>
<type>module</type>
<description>Random hero image module from a configurable folder.</description>
<description>Hero module — image slideshow, video backgrounds, solid color/gradient, parallax.</description>
<icon>icon-image</icon>
<category>Modules</category>
<article>https://mokoconsulting.tech/support/products/mokojoomhero</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/dev/updates.xml</updateserver>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/main/updates.xml</updateserver>
</extension>
<!-- ═══════════════════════════════════════════════════════════════════
Templates
═══════════════════════════════════════════════════════════════════ -->
<extension>
<name>MokoJoomCommunity</name>
<element>pkg_mokojoomcommunity</element>
<type>package</type>
<description>Community Builder integration package with custom fields and user management.</description>
<icon>icon-users</icon>
<category>Community</category>
<article>https://mokoconsulting.tech/support/products/mokojoomcommunity</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCommunity/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>MokoJoomCross</name>
<element>plg_system_mokojoomcross</element>
<type>plugin</type>
<description>Cross-extension integration plugin for Joomla component interoperability.</description>
<icon>icon-link</icon>
<category>Plugins</category>
<article>https://mokoconsulting.tech/support/products/mokojoomcross</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>MokoJoomStoreLocator</name>
<element>mod_mokojoomstorelocator</element>
<type>module</type>
<description>Store locator module with Google Maps integration and search.</description>
<icon>icon-map-marker-alt</icon>
<category>Modules</category>
<article>https://mokoconsulting.tech/support/products/mokojoomstorelocator</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>DPCalendar API</name>
<element>mokodpcalendarapi</element>
<type>plugin</type>
<description>Web Services plugin exposing DPCalendar events and calendars via REST API.</description>
<icon>icon-calendar</icon>
<category>Plugins</category>
<article>https://mokoconsulting.tech/support/products/mokodpcalendarapi</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoDPCalendarAPI/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>Gallery Calendar</name>
<element>mokogallerycalendar</element>
<type>plugin</type>
<description>JoomGallery and DPCalendar integration — link galleries to events.</description>
<icon>icon-images</icon>
<category>Plugins</category>
<article>https://mokoconsulting.tech/support/products/mokogallerycalendar</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoGalleryCalendar/raw/branch/dev/updates.xml</updateserver>
<name>MokoOnyx</name>
<element>mokoonyx</element>
<type>template</type>
<description>Modern Joomla site template with dark mode, custom layouts, and MokoSuite integration.</description>
<icon>icon-paint-brush</icon>
<category>Templates</category>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml</updateserver>
</extension>
</catalog>
@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<config>
<fieldset name="general" label="General" description="General component settings.">
<field name="brand_name" type="text" default="MokoSuiteClient"
<field name="brand_name" type="text" default="MokoSuite"
label="Brand Name"
description="Displayed in the admin sidebar, dashboard, and emails."
hint="MokoSuiteClient" />
hint="MokoSuite" />
<field name="support_email" type="email" default=""
label="Support Email"
description="Reply-to address for outbound notification emails."
hint="support@example.com" />
</fieldset>
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
<fieldset name="notifications" label="Notifications" description="Email and push notification settings.">
<field name="admin_emails" type="text" default=""
label="Admin Email Addresses"
description="Comma-separated email addresses to receive all notifications."
@@ -31,7 +31,7 @@
<field name="spacer_ntfy" type="spacer" label="Push Notifications (ntfy)" />
<field name="ntfy_enabled" type="radio" default="0"
label="Enable ntfy Push"
description="Send push notifications via ntfy for ticket and security events."
description="Send push notifications via ntfy for security and system events."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
@@ -40,13 +40,13 @@
label="ntfy Server URL"
description="Full URL to your ntfy server."
showon="ntfy_enabled:1" />
<field name="ntfy_topic" type="text" default="mokosuiteclient-tickets"
label="Ticket Topic"
description="ntfy topic name for helpdesk ticket notifications."
<field name="ntfy_topic" type="text" default="mokosuite-alerts"
label="Alert Topic"
description="ntfy topic name for general alert notifications."
showon="ntfy_enabled:1" />
<field name="ntfy_security_topic" type="text" default="mokosuiteclient-security"
<field name="ntfy_security_topic" type="text" default="mokosuite-security"
label="Security Topic"
description="ntfy topic name for security alert notifications. Falls back to ticket topic if empty."
description="ntfy topic name for security alerts. Falls back to alert topic if empty."
showon="ntfy_enabled:1" />
<field name="ntfy_token" type="password" default=""
label="ntfy Auth Token"
@@ -54,59 +54,42 @@
showon="ntfy_enabled:1" />
</fieldset>
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
<field name="default_category" type="sql" default=""
label="Default Ticket Category"
description="Category assigned to tickets without a selection."
query="SELECT id AS value, title AS text FROM #__mokosuiteclient_ticket_categories WHERE published = 1 ORDER BY ordering" />
<field name="autoclose_days" type="number" default="7"
label="Auto-Close After (days)"
description="Resolved tickets are auto-closed after this many days. 0 = disabled." />
<field name="kb_search_enabled" type="radio" default="1"
label="KB Search on Ticket Forms"
description="Show knowledge base search before ticket submission."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="satisfaction_enabled" type="radio" default="1"
label="Satisfaction Ratings"
description="Show rating prompt on resolved tickets."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="max_attachment_size" type="number" default="10"
label="Max Attachment Size (MB)"
description="Maximum upload size per file in megabytes." />
<fieldset name="content_tools" label="Content Tools" description="Settings for content tag engines and replacements.">
<field name="spacer_snippets" type="spacer" label="Snippets" />
<field name="snippets_default_category" type="text" default=""
label="Default Snippet Category"
description="Category assigned to new snippets if none selected." />
<field name="spacer_templates" type="spacer" label="Content Templates" />
<field name="templates_default_category" type="text" default=""
label="Default Template Category"
description="Category assigned to new content templates if none selected." />
<field name="spacer_replacements" type="spacer" label="Replacements" />
<field name="replacements_max_rules" type="number" default="100"
label="Max Active Rules"
description="Maximum number of replacement rules processed per page load. 0 = unlimited." />
</fieldset>
<fieldset name="email_to_ticket" label="Email-to-Ticket (IMAP)" description="Create tickets from incoming emails via IMAP polling.">
<field name="imap_host" type="text" default=""
label="IMAP Server"
description="IMAP hostname (e.g. imap.gmail.com)"
hint="imap.gmail.com" />
<field name="imap_port" type="number" default="993"
label="Port"
description="IMAP port (993 for SSL, 143 for plain)" />
<field name="imap_ssl" type="radio" default="1"
label="Use SSL"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="imap_user" type="text" default=""
label="Username"
description="IMAP login username or email address." />
<field name="imap_password" type="password" default=""
label="Password"
description="IMAP password or app-specific password." />
<field name="imap_folder" type="text" default="INBOX"
label="Inbox Folder"
description="IMAP folder to poll for new messages." />
<field name="imap_processed_folder" type="text" default="INBOX.Processed"
label="Processed Folder"
description="Move processed emails to this folder. Leave empty to just mark as read." />
<fieldset name="impersonation" label="User Impersonation" description="Skeleton Key — log into the frontend as another user for support.">
<field name="skeleton_key_control_groups" type="usergrouplist" default="8"
label="Groups Allowed to Impersonate"
description="User groups that can log in as another user."
multiple="true"
layout="joomla.form.field.list-fancy-select" />
<field name="skeleton_key_target_groups" type="usergrouplist" default="2"
label="Groups That Can Be Impersonated"
description="User groups whose accounts can be accessed via impersonation."
multiple="true"
layout="joomla.form.field.list-fancy-select" />
<field name="skeleton_key_blocked_groups" type="usergrouplist" default="7,8"
label="Groups That Cannot Be Impersonated"
description="User groups protected from impersonation (overrides target groups)."
multiple="true"
layout="joomla.form.field.list-fancy-select" />
<field name="skeleton_key_cookie_lifetime" type="number" default="10"
label="Cookie Lifetime (seconds)"
description="How long the impersonation cookie remains valid. Short values are more secure." />
</fieldset>
<fieldset name="permissions" label="COM_MOKOSUITECLIENT_ACL_TITLE"
@@ -1,10 +1,10 @@
; MokoSuiteClient Admin Dashboard - Language Strings
; MokoSuite Admin Dashboard - Language Strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuiteClient Control Panel"
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuite Control Panel"
; Joomla core fallback keys (in case language files are corrupt/missing)
; Joomla core fallback keys
COM_ACTIONLOGS_DISABLED="User Action Logging is disabled. Please enable the &quot;Action Log - Joomla&quot; plugin."
COM_MOKOSUITECLIENT_SITE="Site"
COM_MOKOSUITECLIENT_DATABASE="Database"
@@ -23,22 +23,29 @@ COM_MOKOSUITECLIENT_EXTENSIONS_TITLE="Moko Extensions"
COM_MOKOSUITECLIENT_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism — each package registers its own update server."
COM_MOKOSUITECLIENT_EXTENSIONS_LINK="Moko Extensions"
COM_MOKOSUITECLIENT_HTACCESS_TITLE=".htaccess Maker"
COM_MOKOSUITECLIENT_TICKETS_TITLE="Helpdesk"
; ACL
COM_MOKOSUITECLIENT_ACL_TITLE="Permissions"
COM_MOKOSUITECLIENT_ACL_DESC="Manage access permissions for MokoSuite component features."
COM_MOKOSUITECLIENT_ACL_DASHBOARD="View Dashboard"
COM_MOKOSUITECLIENT_ACL_DASHBOARD_DESC="Allow viewing the MokoSuiteClient control panel dashboard."
COM_MOKOSUITECLIENT_ACL_DASHBOARD_DESC="Allow viewing the MokoSuite control panel dashboard."
COM_MOKOSUITECLIENT_ACL_EXTENSIONS="Manage Extensions"
COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions."
COM_MOKOSUITECLIENT_ACL_HTACCESS="Manage .htaccess"
COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration."
COM_MOKOSUITECLIENT_ACL_TICKETS="View Tickets"
COM_MOKOSUITECLIENT_ACL_TICKETS_DESC="Allow viewing helpdesk tickets."
COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE="Create Tickets"
COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets."
COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN="Assign Tickets"
COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users."
COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE="Toggle Plugins"
COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoSuiteClient feature plugins."
COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoSuite feature plugins."
COM_MOKOSUITECLIENT_ACL_CACHE="Clear Cache"
COM_MOKOSUITECLIENT_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
COM_MOKOSUITECLIENT_ACL_HTACCESS="Manage .htaccess"
COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess and nginx configuration."
COM_MOKOSUITECLIENT_ACL_WAFLOG="View WAF Log"
COM_MOKOSUITECLIENT_ACL_WAFLOG_DESC="Allow viewing the Web Application Firewall activity log."
COM_MOKOSUITECLIENT_ACL_IMPERSONATE="Impersonate Users"
COM_MOKOSUITECLIENT_ACL_IMPERSONATE_DESC="Allow logging into the frontend as another user for support purposes."
COM_MOKOSUITECLIENT_ACL_SNIPPETS="Manage Snippets"
COM_MOKOSUITECLIENT_ACL_SNIPPETS_DESC="Allow creating, editing, and deleting reusable content snippets."
COM_MOKOSUITECLIENT_ACL_TEMPLATES="Manage Content Templates"
COM_MOKOSUITECLIENT_ACL_TEMPLATES_DESC="Allow creating, editing, and deleting article content templates."
COM_MOKOSUITECLIENT_ACL_REPLACEMENTS="Manage Replacements"
COM_MOKOSUITECLIENT_ACL_REPLACEMENTS_DESC="Allow creating, editing, and deleting text replacement rules."
COM_MOKOSUITECLIENT_ACL_CONDITIONS="Manage Conditions"
COM_MOKOSUITECLIENT_ACL_CONDITIONS_DESC="Allow creating, editing, and deleting display condition sets for modules and content."
@@ -227,3 +227,126 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_license_cache` (
PRIMARY KEY (`dlid_hash`),
KEY `idx_checked` (`checked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Conditions Engine — rule-based display conditions
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`alias` VARCHAR(100) NOT NULL DEFAULT '',
`name` VARCHAR(100) NOT NULL DEFAULT '',
`description` TEXT NOT NULL,
`category` VARCHAR(50) NOT NULL DEFAULT '',
`color` VARCHAR(8) DEFAULT NULL,
`match_all` TINYINT(1) NOT NULL DEFAULT 1,
`published` TINYINT(1) NOT NULL DEFAULT 1,
`hash` VARCHAR(32) NOT NULL DEFAULT '',
`checked_out` INT UNSIGNED DEFAULT NULL,
`checked_out_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_alias` (`alias`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions_groups` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`condition_id` INT UNSIGNED NOT NULL,
`match_all` TINYINT(1) NOT NULL DEFAULT 1,
`ordering` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_condition` (`condition_id`),
KEY `idx_ordering` (`condition_id`, `ordering`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions_rules` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`group_id` INT UNSIGNED NOT NULL,
`type` VARCHAR(50) NOT NULL DEFAULT '',
`exclude` TINYINT(1) NOT NULL DEFAULT 0,
`params` TEXT NOT NULL,
`ordering` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_group` (`group_id`),
KEY `idx_type` (`type`),
KEY `idx_ordering` (`group_id`, `ordering`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions_map` (
`condition_id` INT UNSIGNED NOT NULL,
`extension` VARCHAR(50) NOT NULL DEFAULT '',
`item_id` INT UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY `idx_unique` (`condition_id`, `item_id`, `extension`),
KEY `idx_ext_item` (`extension`, `item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- Snippets — reusable text/HTML blocks insertable via {snippet}
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_snippets` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`alias` VARCHAR(100) NOT NULL DEFAULT '',
`name` VARCHAR(100) NOT NULL DEFAULT '',
`description` TEXT NOT NULL,
`category` VARCHAR(50) NOT NULL DEFAULT '',
`color` VARCHAR(8) DEFAULT NULL,
`content` MEDIUMTEXT NOT NULL,
`params` TEXT NOT NULL,
`published` TINYINT(1) NOT NULL DEFAULT 0,
`ordering` INT NOT NULL DEFAULT 0,
`checked_out` INT UNSIGNED DEFAULT NULL,
`checked_out_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_alias` (`alias`),
KEY `idx_published` (`published`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- ReReplacer — backend-managed string/regex replacement rules
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_replacements` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL DEFAULT '',
`search` TEXT NOT NULL,
`replace_value` TEXT NOT NULL,
`area` VARCHAR(20) NOT NULL DEFAULT 'both',
`regex` TINYINT(1) NOT NULL DEFAULT 0,
`casesensitive` TINYINT(1) NOT NULL DEFAULT 0,
`category` VARCHAR(50) NOT NULL DEFAULT '',
`published` TINYINT(1) NOT NULL DEFAULT 0,
`description` TEXT NOT NULL,
`enable_in_admin` TINYINT(1) NOT NULL DEFAULT 0,
`color` VARCHAR(8) DEFAULT NULL,
`ordering` INT NOT NULL DEFAULT 0,
`checked_out` INT UNSIGNED DEFAULT NULL,
`checked_out_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_published` (`published`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- Content Templates
--
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_content_templates` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`alias` VARCHAR(100) NOT NULL DEFAULT '',
`name` VARCHAR(255) NOT NULL DEFAULT '',
`description` TEXT NOT NULL,
`category` VARCHAR(50) NOT NULL DEFAULT '',
`color` VARCHAR(8) DEFAULT NULL,
`template_data` MEDIUMTEXT NOT NULL,
`joomla_category_id` INT NOT NULL DEFAULT 0,
`access` INT UNSIGNED NOT NULL DEFAULT 1,
`published` TINYINT(1) NOT NULL DEFAULT 1,
`ordering` INT NOT NULL DEFAULT 0,
`checked_out` INT UNSIGNED DEFAULT NULL,
`checked_out_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_alias` (`alias`),
KEY `idx_category` (`joomla_category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1 @@
/* 02.47.95 — no schema changes */
@@ -0,0 +1,108 @@
-- Regular Labs replacement tables (conditions, snippets, replacements, content templates)
-- These were in install.mysql.sql but missing from updates, so existing installs never got them.
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;
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;
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;
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_content_templates` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`alias` VARCHAR(100) NOT NULL DEFAULT '',
`name` VARCHAR(255) NOT NULL DEFAULT '',
`description` TEXT NOT NULL,
`category` VARCHAR(50) NOT NULL DEFAULT '',
`color` VARCHAR(8) DEFAULT NULL,
`template_data` MEDIUMTEXT NOT NULL,
`joomla_category_id` INT NOT NULL DEFAULT 0,
`access` INT UNSIGNED NOT NULL DEFAULT 1,
`published` TINYINT(1) NOT NULL DEFAULT 1,
`ordering` INT NOT NULL DEFAULT 0,
`checked_out` INT UNSIGNED DEFAULT NULL,
`checked_out_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_alias` (`alias`),
KEY `idx_category` (`joomla_category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -24,19 +24,18 @@ class DisplayController extends BaseController
* ACL map: view name => required permission.
*/
private const VIEW_ACL = [
'dashboard' => 'mokosuiteclient.dashboard',
'extensions' => 'mokosuiteclient.extensions',
'htaccess' => 'mokosuiteclient.htaccess',
'tickets' => 'mokosuiteclient.tickets',
'ticket' => 'mokosuiteclient.tickets',
'privacy' => 'core.admin',
'waflog' => 'core.admin',
'categories' => 'mokosuiteclient.tickets',
'canned' => 'mokosuiteclient.tickets',
'automation' => 'core.admin',
'database' => 'core.admin',
'cleanup' => 'mokosuiteclient.cache',
'ticketsettings' => 'core.admin',
'dashboard' => 'mokosuiteclient.dashboard',
'extensions' => 'mokosuiteclient.extensions',
'htaccess' => 'mokosuiteclient.htaccess',
'privacy' => 'core.admin',
'waflog' => 'mokosuiteclient.security.waflog',
'automation' => 'core.admin',
'database' => 'core.admin',
'cleanup' => 'mokosuiteclient.cache',
'snippets' => 'mokosuiteclient.snippets.manage',
'templates' => 'mokosuiteclient.templates.manage',
'replacements' => 'mokosuiteclient.replacements.manage',
'conditions' => 'mokosuiteclient.conditions.manage',
];
public function display($cachable = false, $urlparams = [])
@@ -142,6 +141,22 @@ class DisplayController extends BaseController
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
$timestamp = time();
// Discover all MokoSuite ecosystem packages for HQ
$mokoPackages = [];
try {
$pkgDb = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$pkgQuery = $pkgDb->getQuery(true)
->select([$pkgDb->quoteName('element'), $pkgDb->quoteName('manifest_cache')])
->from($pkgDb->quoteName('#__extensions'))
->where('(' . $pkgDb->quoteName('element') . ' LIKE ' . $pkgDb->quote('pkg_mokosuite%')
. ' OR ' . $pkgDb->quoteName('element') . ' LIKE ' . $pkgDb->quote('pkg_mokojoom%') . ')');
$pkgDb->setQuery($pkgQuery);
foreach ($pkgDb->loadObjectList() ?: [] as $pkg) {
$m = json_decode($pkg->manifest_cache ?? '{}');
$mokoPackages[$pkg->element] = $m->version ?? '';
}
} catch (\Throwable $e) {}
$payload = json_encode([
'token' => $healthToken,
'domain' => $domain,
@@ -150,6 +165,7 @@ class DisplayController extends BaseController
'joomla_version' => (new \Joomla\CMS\Version())->getShortVersion(),
'php_version' => PHP_VERSION,
'timestamp' => $timestamp,
'moko_packages' => $mokoPackages,
], JSON_UNESCAPED_SLASHES);
// RSA sign the request
@@ -348,75 +364,10 @@ class DisplayController extends BaseController
}
// ==================================================================
// Tickets
// Regular Labs Import
// ==================================================================
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(
$input->getInt('ticket_id', 0),
$input->getInt('status', 0)
));
}
// ==================================================================
// Ticket Settings — Status/Priority CRUD
// ==================================================================
public function saveStatus()
public function importRegularLabs()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
@@ -426,108 +377,165 @@ class DisplayController extends BaseController
return;
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->saveStatus([
'id' => $input->getInt('id', 0),
'title' => $input->getString('title', ''),
'alias' => $input->getString('alias', ''),
'color' => $input->getString('color', 'bg-secondary'),
'is_default' => $input->getInt('is_default', 0),
'is_closed' => $input->getInt('is_closed', 0),
'ordering' => $input->getInt('ordering', 0),
]));
}
public function deleteStatus()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->jsonResponse($this->getModel('Tickets')->deleteStatus($id));
}
public function savePriority()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->savePriority([
'id' => $input->getInt('id', 0),
'title' => $input->getString('title', ''),
'alias' => $input->getString('alias', ''),
'color' => $input->getString('color', 'bg-secondary'),
'is_default' => $input->getInt('is_default', 0),
'ordering' => $input->getInt('ordering', 0),
]));
}
public function deletePriority()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->jsonResponse($this->getModel('Tickets')->deletePriority($id));
}
// ==================================================================
// KB Search
// ==================================================================
public function searchKb()
{
$query = Factory::getApplication()->getInput()->getString('q', '');
if (strlen($query) < 3)
{
$this->jsonResponse(['results' => []]);
return;
}
try
{
$db = Factory::getDbo();
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$prefix = $db->getPrefix();
$tables = $db->getTableList();
$results = [];
$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)
// ── Conditions (4 tables) ──────────────────────────────
if (in_array($prefix . 'conditions', $tables)
&& in_array($prefix . 'mokosuiteclient_conditions', $tables))
{
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
// Check if already imported
$existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_conditions'))->loadResult();
if ($existing === 0)
{
// conditions
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions')
. " (id, alias, name, description, category, color, match_all, published, hash, checked_out, checked_out_time)"
. " SELECT id, alias, name, description, category, color, match_all, published, hash, checked_out, checked_out_time"
. " FROM " . $db->quoteName('#__conditions'))->execute();
$c1 = $db->getAffectedRows();
// conditions_groups
if (in_array($prefix . 'conditions_groups', $tables))
{
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions_groups')
. " (id, condition_id, match_all, ordering)"
. " SELECT id, condition_id, match_all, ordering"
. " FROM " . $db->quoteName('#__conditions_groups'))->execute();
}
// conditions_rules
if (in_array($prefix . 'conditions_rules', $tables))
{
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions_rules')
. " (id, group_id, type, exclude, params, ordering)"
. " SELECT id, group_id, type, exclude, params, ordering"
. " FROM " . $db->quoteName('#__conditions_rules'))->execute();
}
// conditions_map
if (in_array($prefix . 'conditions_map', $tables))
{
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions_map')
. " (condition_id, extension, item_id)"
. " SELECT condition_id, extension, item_id"
. " FROM " . $db->quoteName('#__conditions_map'))->execute();
}
$results['conditions'] = $c1 . ' condition sets imported';
}
else
{
$results['conditions'] = 'skipped (already has data)';
}
}
$this->jsonResponse(['results' => $results]);
// ── Snippets ──────────────────────────────────────────
if (in_array($prefix . 'snippets', $tables)
&& in_array($prefix . 'mokosuiteclient_snippets', $tables))
{
$existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_snippets'))->loadResult();
if ($existing === 0)
{
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_snippets')
. " (id, alias, name, description, category, color, content, params, published, ordering, checked_out, checked_out_time)"
. " SELECT id, alias, name, description, category, color, content, params, published, ordering, checked_out, checked_out_time"
. " FROM " . $db->quoteName('#__snippets'))->execute();
$results['snippets'] = $db->getAffectedRows() . ' snippets imported';
}
else
{
$results['snippets'] = 'skipped (already has data)';
}
}
// ── ReReplacer ────────────────────────────────────────
if (in_array($prefix . 'rereplacer', $tables)
&& in_array($prefix . 'mokosuiteclient_replacements', $tables))
{
$existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_replacements'))->loadResult();
if ($existing === 0)
{
// RL uses 'replace' column, we use 'replace_value'; RL 'area' is text (JSON), we use varchar
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_replacements')
. " (id, name, search, replace_value, area, published, description, ordering, checked_out, checked_out_time)"
. " SELECT id, name, search, `replace`, 'both', published, description, ordering, checked_out, checked_out_time"
. " FROM " . $db->quoteName('#__rereplacer'))->execute();
$results['replacements'] = $db->getAffectedRows() . ' replacement rules imported';
}
else
{
$results['replacements'] = 'skipped (already has data)';
}
}
// ── Content Templater ─────────────────────────────────
if (in_array($prefix . 'contenttemplater', $tables)
&& in_array($prefix . 'mokosuiteclient_content_templates', $tables))
{
$existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_content_templates'))->loadResult();
if ($existing === 0)
{
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_content_templates')
. " (id, name, description, category, color, template_data, published, ordering, checked_out, checked_out_time)"
. " SELECT id, name, description, category, color, content, published, ordering, checked_out, checked_out_time"
. " FROM " . $db->quoteName('#__contenttemplater'))->execute();
$results['templates'] = $db->getAffectedRows() . ' content templates imported';
}
else
{
$results['templates'] = 'skipped (already has data)';
}
}
if (empty($results))
{
$this->jsonResponse(['success' => false, 'message' => 'No Regular Labs data found to import.']);
}
else
{
$summary = implode('; ', array_map(fn($k, $v) => ucfirst($k) . ': ' . $v, array_keys($results), $results));
$this->jsonResponse(['success' => true, 'message' => 'Import complete. ' . $summary]);
}
}
catch (\Throwable $e)
{
Log::add('KB search failed: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
$this->jsonResponse(['results' => [], 'error' => 'Search unavailable']);
$this->jsonResponse(['success' => false, 'message' => 'Import error: ' . $e->getMessage()]);
}
}
// ==================================================================
// Support PIN
// ==================================================================
public function requestPin()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.dashboard'))
{
$this->jsonForbidden();
return;
}
try
{
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$result = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::requestNew($db);
$this->jsonResponse($result);
}
catch (\Throwable $e)
{
$this->jsonResponse(['success' => false, 'message' => 'Error: ' . $e->getMessage()]);
}
}
@@ -568,218 +576,6 @@ class DisplayController extends BaseController
$this->jsonResponse($model->cleanDirectory($dirKey));
}
// ==================================================================
// Helpdesk CRUD (#137, #138, #139)
// ==================================================================
public function saveCategory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$id = $input->getInt('id', 0);
$data = (object) [
'title' => $input->getString('title', ''),
'alias' => \Joomla\CMS\Filter\OutputFilter::stringURLSafe($input->getString('title', '')),
'sla_response_minutes' => $input->getInt('sla_response_minutes', 480),
'sla_resolution_minutes' => $input->getInt('sla_resolution_minutes', 2880),
'auto_assign_user' => $input->getInt('auto_assign_user', 0) ?: null,
'published' => $input->getInt('published', 1),
];
if ($id) {
$data->id = $id;
$db->updateObject('#__mokosuiteclient_ticket_categories', $data, 'id');
} else {
$data->ordering = 0;
$db->insertObject('#__mokosuiteclient_ticket_categories', $data, 'id');
}
$this->jsonResponse(['success' => true, 'message' => 'Category saved.', 'id' => (int) $data->id]);
}
public function deleteCategory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_categories')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
}
public function reorderCategory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
$db = Factory::getDbo();
foreach ($order as $i => $id) {
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_categories') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
}
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
}
public function saveCanned()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$data = (object) [
'title' => $input->getString('title', ''),
'body' => $input->getRaw('body', ''),
'category_id' => $input->getInt('category_id', 0) ?: null,
'ordering' => 0,
];
$id = $input->getInt('id', 0);
if ($id) { $data->id = $id; $db->updateObject('#__mokosuiteclient_ticket_canned', $data, 'id'); }
else { $db->insertObject('#__mokosuiteclient_ticket_canned', $data, 'id'); }
$this->jsonResponse(['success' => true, 'message' => 'Canned response saved.', 'id' => (int) $data->id]);
}
public function deleteCanned()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_canned')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
}
public function reorderCanned()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
$db = Factory::getDbo();
foreach ($order as $i => $id) {
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_canned') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
}
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
}
public function uploadAttachment()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('ticket_id', 0);
$replyId = $input->getInt('reply_id', 0) ?: null;
if (!$ticketId) { $this->jsonResponse(['success' => false, 'message' => 'Missing ticket_id']); return; }
$files = $input->files->get('attachments', [], 'raw');
if (empty($files) || empty($files['name'])) { $this->jsonResponse(['success' => false, 'message' => 'No files uploaded']); return; }
$saved = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::upload($ticketId, $replyId, $files);
$this->jsonResponse(['success' => true, 'message' => count($saved) . ' file(s) uploaded', 'count' => count($saved)]);
}
public function downloadAttachment()
{
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_ticket_attachments')->where('id = ' . $id));
$att = $db->loadObject();
if (!$att) { throw new \RuntimeException('Attachment not found', 404); }
$path = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getAbsolutePath($att);
if (!file_exists($path)) { throw new \RuntimeException('File not found', 404); }
$app = Factory::getApplication();
$app->setHeader('Content-Type', $att->mimetype ?: 'application/octet-stream');
$safeName = str_replace(['"', "\r", "\n"], '', $att->filename);
$app->setHeader('Content-Disposition', 'attachment; filename="' . $safeName . '"');
$app->setHeader('Content-Length', (string) filesize($path));
$app->sendHeaders();
readfile($path);
$app->close();
}
public function deleteAttachment()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$ok = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::delete($id);
$this->jsonResponse(['success' => $ok, 'message' => $ok ? 'Attachment deleted' : 'Not found']);
}
public function rateTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('ticket_id', 0);
$rating = $input->getInt('rating', 0);
$feedback = $input->getString('feedback', '');
if (!$ticketId || $rating < 1 || $rating > 5) {
$this->jsonResponse(['success' => false, 'message' => 'Invalid rating (1-5)']);
return;
}
$db = Factory::getDbo();
$db->setQuery(
'UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets')
. ' SET satisfaction_rating = ' . $rating
. ', satisfaction_feedback = ' . $db->quote($feedback)
. ', satisfaction_rated_at = ' . $db->quote(Factory::getDate()->toSql())
. ' WHERE id = ' . $ticketId
)->execute();
$this->jsonResponse(['success' => true, 'message' => 'Thank you for your feedback!']);
}
public function saveAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$data = (object) [
'title' => $input->getString('title', ''),
'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
'conditions' => $input->getRaw('conditions', '[]'),
'actions' => $input->getRaw('actions', '[]'),
'behavior' => $input->getString('behavior', 'append'),
'enabled' => 1,
'ordering' => 0,
];
$id = $input->getInt('id', 0);
if ($id) { $data->id = $id; $db->updateObject('#__mokosuiteclient_ticket_automation', $data, 'id'); }
else { $db->insertObject('#__mokosuiteclient_ticket_automation', $data, 'id'); }
$this->jsonResponse(['success' => true, 'message' => 'Rule saved.', 'id' => (int) $data->id]);
}
public function deleteAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_automation')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Rule deleted.']);
}
public function toggleAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->update('#__mokosuiteclient_ticket_automation')
->set('enabled = ' . $input->getInt('enabled', 0))
->where('id = ' . $input->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
}
public function reorderAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
$db = Factory::getDbo();
foreach ($order as $i => $id) {
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_automation') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
}
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
}
// ==================================================================
// Settings Import/Export (#132)
// ==================================================================
@@ -891,7 +687,7 @@ class DisplayController extends BaseController
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
if (!$this->checkAcl('mokosuiteclient.security.waflog'))
{
$this->jsonForbidden();
return;
@@ -907,7 +703,7 @@ class DisplayController extends BaseController
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
if (!$this->checkAcl('mokosuiteclient.security.waflog'))
{
$this->jsonForbidden();
return;
@@ -991,19 +787,6 @@ class DisplayController extends BaseController
// Importers
// ==================================================================
public function importAts()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets'))
{
$this->jsonForbidden();
return;
}
$this->jsonResponse($this->getModel('Import')->importAts());
}
public function importAdminTools()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
@@ -0,0 +1,525 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage com_mokosuiteclient
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteClient\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Uri\Uri;
/**
* Conditions Engine — evaluates rule-based display conditions.
*
* Supports nested groups of rules with AND/OR logic and per-rule exclusion.
*
* @since 02.48.00
*/
class ConditionsHelper
{
/**
* Runtime evaluation cache keyed by condition ID.
*
* @var array<int, bool>
*/
private static array $cache = [];
/**
* Check whether a condition set passes.
*
* @param int $conditionId The condition record ID.
*
* @return bool True when the condition passes (content should display).
*/
public static function pass(int $conditionId): bool
{
if (isset(self::$cache[$conditionId])) {
return self::$cache[$conditionId];
}
$condition = self::load($conditionId);
if ($condition === null || !(int) $condition->published) {
self::$cache[$conditionId] = false;
return false;
}
$groups = $condition->groups ?? [];
if (empty($groups)) {
// No groups means no restrictions — pass.
self::$cache[$conditionId] = true;
return true;
}
$matchAll = (bool) $condition->match_all;
foreach ($groups as $group) {
$groupResult = self::passGroup($group);
if ($matchAll && !$groupResult) {
self::$cache[$conditionId] = false;
return false;
}
if (!$matchAll && $groupResult) {
self::$cache[$conditionId] = true;
return true;
}
}
// match_all: all passed; match_any: none passed.
$result = $matchAll;
self::$cache[$conditionId] = $result;
return $result;
}
/**
* Load a condition with its groups and rules from the database.
*
* @param int $conditionId The condition record ID.
*
* @return object|null The condition object with nested groups/rules, or null.
*/
public static function load(int $conditionId): ?object
{
$db = Factory::getContainer()->get('DatabaseDriver');
// Load the condition record.
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuiteclient_conditions'))
->where($db->quoteName('id') . ' = :id')
->bind(':id', $conditionId, \Joomla\Database\ParameterType::INTEGER);
$condition = $db->setQuery($query)->loadObject();
if ($condition === null) {
return null;
}
// Load groups.
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuiteclient_conditions_groups'))
->where($db->quoteName('condition_id') . ' = :cid')
->bind(':cid', $conditionId, \Joomla\Database\ParameterType::INTEGER)
->order($db->quoteName('ordering') . ' ASC');
$groups = $db->setQuery($query)->loadObjectList();
// Load rules for each group.
foreach ($groups as $group) {
$groupId = (int) $group->id;
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuiteclient_conditions_rules'))
->where($db->quoteName('group_id') . ' = :gid')
->bind(':gid', $groupId, \Joomla\Database\ParameterType::INTEGER)
->order($db->quoteName('ordering') . ' ASC');
$group->rules = $db->setQuery($query)->loadObjectList();
// Decode params JSON on each rule.
foreach ($group->rules as $rule) {
$rule->params = json_decode($rule->params ?: '{}');
}
}
$condition->groups = $groups;
return $condition;
}
/**
* Evaluate a single group (AND/OR its rules).
*
* @param object $group The group object with a rules array.
*
* @return bool
*/
private static function passGroup(object $group): bool
{
$rules = $group->rules ?? [];
if (empty($rules)) {
return true;
}
$matchAll = (bool) $group->match_all;
foreach ($rules as $rule) {
$ruleResult = self::passRule($rule);
// If the rule is an exclusion, invert the result.
if ((int) $rule->exclude) {
$ruleResult = !$ruleResult;
}
if ($matchAll && !$ruleResult) {
return false;
}
if (!$matchAll && $ruleResult) {
return true;
}
}
return $matchAll;
}
/**
* Evaluate a single rule by dispatching to the right type handler.
*
* @param object $rule The rule object (type, params decoded).
*
* @return bool
*/
private static function passRule(object $rule): bool
{
$params = $rule->params ?? new \stdClass();
return match ($rule->type) {
'menu__menu_item' => self::evalMenuMenuItem($params),
'menu__home_page' => self::evalMenuHomePage($params),
'visitor__user_group' => self::evalVisitorUserGroup($params),
'visitor__access_level' => self::evalVisitorAccessLevel($params),
'date__date' => self::evalDateDate($params),
'date__day' => self::evalDateDay($params),
'other__url' => self::evalOtherUrl($params),
default => false,
};
}
// ------------------------------------------------------------------
// Rule type evaluators
// ------------------------------------------------------------------
/**
* menu__menu_item — check if current menu item ID is in selection.
*/
private static function evalMenuMenuItem(object $params): bool
{
$selection = self::toIntArray($params->selection ?? []);
if (empty($selection)) {
return true;
}
$app = Factory::getApplication();
$itemId = (int) $app->getInput()->getInt('Itemid', 0);
return \in_array($itemId, $selection, true);
}
/**
* menu__home_page — check if current page is the site home page.
*/
private static function evalMenuHomePage(object $params): bool
{
$app = Factory::getApplication();
$menu = $app->getMenu();
if ($menu === null) {
return false;
}
$active = $menu->getActive();
$default = $menu->getDefault($app->getLanguage()->getTag());
$isHome = ($active !== null && $default !== null && $active->id === $default->id);
// params->selection can be [1] for "is home" or [0] for "is not home".
$want = (bool) ($params->selection[0] ?? true);
return $isHome === $want;
}
/**
* visitor__user_group — check if current user belongs to specified groups.
*/
private static function evalVisitorUserGroup(object $params): bool
{
$selection = self::toIntArray($params->selection ?? []);
if (empty($selection)) {
return true;
}
$user = Factory::getApplication()->getIdentity();
$userGroups = $user ? $user->getAuthorisedGroups() : [];
$comparison = $params->comparison ?? 'any';
if ($comparison === 'all') {
return empty(array_diff($selection, $userGroups));
}
// Default: any
return !empty(array_intersect($selection, $userGroups));
}
/**
* visitor__access_level — check if current user has specified access levels.
*/
private static function evalVisitorAccessLevel(object $params): bool
{
$selection = self::toIntArray($params->selection ?? []);
if (empty($selection)) {
return true;
}
$user = Factory::getApplication()->getIdentity();
$accessLevels = $user ? $user->getAuthorisedViewLevels() : [];
$comparison = $params->comparison ?? 'any';
if ($comparison === 'all') {
return empty(array_diff($selection, $accessLevels));
}
return !empty(array_intersect($selection, $accessLevels));
}
/**
* date__date — check if current date is before/after/between specified dates.
*
* params->comparison: 'before', 'after', 'between'
* params->selection: [start_date] or [start_date, end_date]
*/
private static function evalDateDate(object $params): bool
{
$comparison = $params->comparison ?? 'after';
$selection = (array) ($params->selection ?? []);
if (empty($selection)) {
return true;
}
$now = Factory::getDate()->toUnix();
return match ($comparison) {
'before' => $now < strtotime($selection[0]),
'after' => $now > strtotime($selection[0]),
'between' => isset($selection[1])
&& $now >= strtotime($selection[0])
&& $now <= strtotime($selection[1]),
default => false,
};
}
/**
* date__day — check if current day of week matches selection.
*
* params->selection: array of day numbers (1=Monday .. 7=Sunday, ISO-8601).
*/
private static function evalDateDay(object $params): bool
{
$selection = self::toIntArray($params->selection ?? []);
if (empty($selection)) {
return true;
}
$today = (int) Factory::getDate()->format('N'); // 1=Mon, 7=Sun
return \in_array($today, $selection, true);
}
/**
* other__url — check if current URL matches a regex pattern.
*
* params->selection: array of regex patterns (without delimiters).
*/
private static function evalOtherUrl(object $params): bool
{
$patterns = (array) ($params->selection ?? []);
if (empty($patterns)) {
return true;
}
$url = Uri::getInstance()->toString();
foreach ($patterns as $pattern) {
$pattern = trim($pattern);
if ($pattern === '') {
continue;
}
// Wrap in delimiters, escape internal delimiter.
$safePattern = str_replace('#', '\\#', $pattern);
if (@preg_match('#' . $safePattern . '#i', $url)) {
return true;
}
}
return false;
}
// ------------------------------------------------------------------
// Mapping helpers
// ------------------------------------------------------------------
/**
* Get all condition IDs mapped to a specific extension/item pair.
*
* @param string $extension The extension identifier (e.g. 'mod_custom').
* @param int $itemId The item ID within that extension.
*
* @return int[] Array of condition IDs.
*/
public static function getConditionsForItem(string $extension, int $itemId): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select($db->quoteName('condition_id'))
->from($db->quoteName('#__mokosuiteclient_conditions_map'))
->where($db->quoteName('extension') . ' = :ext')
->where($db->quoteName('item_id') . ' = :iid')
->bind(':ext', $extension)
->bind(':iid', $itemId, \Joomla\Database\ParameterType::INTEGER);
return $db->setQuery($query)->loadColumn();
}
/**
* Check if an item should display based on its mapped conditions.
*
* If no conditions are mapped, the item displays (returns true).
* If conditions are mapped, ALL must pass for the item to display.
*
* @param string $extension The extension identifier.
* @param int $itemId The item ID.
*
* @return bool
*/
public static function shouldDisplay(string $extension, int $itemId): bool
{
$conditionIds = self::getConditionsForItem($extension, $itemId);
if (empty($conditionIds)) {
return true;
}
foreach ($conditionIds as $conditionId) {
if (!self::pass((int) $conditionId)) {
return false;
}
}
return true;
}
/**
* Evaluate a condition by its alias string.
*
* @param string $alias The condition alias.
*
* @return bool True when the condition passes.
*
* @since 02.48.00
*/
public static function passByAlias(string $alias): bool
{
$id = self::resolveAlias($alias);
if ($id === null) {
return false;
}
return self::pass($id);
}
/**
* Resolve a condition reference that may be an integer ID or an alias string.
*
* @param string $ref The reference (numeric ID or alias).
*
* @return int|null The condition ID, or null if not found.
*
* @since 02.48.00
*/
public static function resolveAlias(string $ref): ?int
{
if (is_numeric($ref)) {
return (int) $ref;
}
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokosuiteclient_conditions'))
->where($db->quoteName('alias') . ' = :alias')
->bind(':alias', $ref);
$id = $db->setQuery($query)->loadResult();
return $id !== null ? (int) $id : null;
}
/**
* Evaluate a single inline rule (public wrapper around passRule).
*
* @param string $type The rule type (e.g. 'visitor__access_level').
* @param object $params The rule params object.
*
* @return bool
*
* @since 02.48.00
*/
public static function evaluateInlineRule(string $type, object $params): bool
{
$rule = (object) [
'type' => $type,
'params' => $params,
];
return self::passRule($rule);
}
/**
* Clear the evaluation cache (useful between requests in testing).
*
* @return void
*/
public static function clearCache(): void
{
self::$cache = [];
}
// ------------------------------------------------------------------
// Internal utilities
// ------------------------------------------------------------------
/**
* Normalize a mixed selection value into an array of integers.
*
* @param mixed $value Scalar, array, or object.
*
* @return int[]
*/
private static function toIntArray(mixed $value): array
{
if (\is_object($value)) {
$value = (array) $value;
}
if (!\is_array($value)) {
$value = [$value];
}
return array_map('intval', array_values($value));
}
}
@@ -0,0 +1,286 @@
<?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\Database\DatabaseInterface;
/**
* Shared Support PIN helper.
*
* Generates HMAC-based support PINs from the core plugin's health token.
* Used by the component dashboard, cpanel module, cache module, and
* the requestPin AJAX controller.
*
* @since 02.48.00
*/
class SupportPinHelper
{
/** @var int Default PIN validity window in seconds (72 hours) */
public const PIN_TTL_DEFAULT = 72 * 3600;
/**
* Load core plugin params and return PIN state.
*
* @param DatabaseInterface $db Database driver.
*
* @return array{available: bool, pin: string, token: string, params: array}
*/
public static function getState(DatabaseInterface $db): array
{
$result = [
'available' => false,
'pin' => '',
'token' => '',
'params' => [],
'ext_id' => 0,
];
try
{
$query = $db->getQuery(true)
->select([$db->quoteName('extension_id'), $db->quoteName('params')])
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
$db->setQuery($query);
$ext = $db->loadObject();
if (!$ext)
{
return $result;
}
$params = json_decode($ext->params, true) ?: [];
$token = $params['health_api_token'] ?? '';
$result['params'] = $params;
$result['ext_id'] = (int) $ext->extension_id;
$result['token'] = $token;
if (empty($token))
{
return $result;
}
$result['available'] = true;
$pinTtl = (int) ($params['support_pin_hours'] ?? 0) * 3600 ?: self::PIN_TTL_DEFAULT;
$requestedAt = (int) ($params['support_pin_requested_at'] ?? 0);
if ($requestedAt && (time() - $requestedAt) < $pinTtl)
{
$result['pin'] = self::generate($token, $requestedAt, $pinTtl);
}
}
catch (\Throwable $e)
{
// Silently degrade — PIN is non-critical UI sugar
}
return $result;
}
/**
* Generate a PIN string from a token and timestamp.
*
* @param string $token Health API token (HMAC key).
* @param int $timestamp The request timestamp.
* @param int $ttl PIN validity window in seconds.
*
* @return string e.g. "MOKO-A1B2-C3D4"
*/
public static function generate(string $token, int $timestamp, int $ttl = 0): string
{
$ttl = $ttl ?: self::PIN_TTL_DEFAULT;
$window = floor($timestamp / $ttl);
$hash = hash_hmac('sha256', (string) $window, $token);
return 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
}
/**
* Render PIN badge HTML (active PIN with copy, or request button).
*
* @param array $state Result from getState().
* @param string $token CSRF form token name.
* @param string $context 'dashboard'|'cpanel'|'cache' — controls layout variant.
*
* @return string HTML fragment (no wrapping div).
*/
public static function renderBadge(array $state, string $token, string $context = 'dashboard'): string
{
if (!$state['available'])
{
return '';
}
$requestUrl = \Joomla\CMS\Router\Route::_('index.php?option=com_mokosuiteclient&task=display.requestPin&format=json');
$pin = $state['pin'];
$html = '';
if (!empty($pin))
{
$escaped = htmlspecialchars($pin, ENT_QUOTES, 'UTF-8');
if ($context === 'cache')
{
$html .= '<a href="#" class="btn btn-sm btn-outline-secondary rounded-0 border-end-0 d-flex align-items-center gap-1 px-3 py-2 mokosuiteclient-pin-copy" data-pin="' . $escaped . '" title="Support PIN — click to copy" style="font-size:0.8rem;">';
$html .= '<span class="icon-key" aria-hidden="true"></span>';
$html .= '<span class="mokosuiteclient-pin-text">' . $escaped . '</span>';
$html .= '</a>';
}
else
{
$html .= '<span class="badge bg-dark mokosuiteclient-pin-copy" style="font-family:monospace;letter-spacing:0.08em;cursor:pointer;" title="Click to copy" data-pin="' . $escaped . '">';
$html .= '<span class="icon-key small me-1" aria-hidden="true"></span><span class="mokosuiteclient-pin-text">' . $escaped . '</span></span>';
}
}
else
{
if ($context === 'cache')
{
$html .= '<a href="#" class="btn btn-sm btn-outline-secondary rounded-0 border-end-0 d-flex align-items-center gap-1 px-3 py-2 mokosuiteclient-pin-request" data-url="' . $requestUrl . '" data-token="' . $token . '" title="Request support PIN" style="font-size:0.8rem;">';
$html .= '<span class="icon-key" aria-hidden="true"></span>';
$html .= '<span class="mokosuiteclient-pin-text">PIN</span>';
$html .= '</a>';
}
else
{
$html .= '<button type="button" class="btn btn-sm btn-outline-dark py-0 px-2 mokosuiteclient-pin-request" data-url="' . $requestUrl . '" data-token="' . $token . '" style="font-size:0.75rem;" title="Request a support PIN">';
$html .= '<span class="icon-key" aria-hidden="true"></span> Request PIN</button>';
}
}
return $html;
}
/**
* Render shared JS for PIN copy and request functionality.
*
* @return string <script> block.
*/
public static function renderScript(): string
{
return <<<'JS'
<script>
(function() {
if (window._mokoPinBound) return;
window._mokoPinBound = true;
function bindCopy(el) {
if (el.dataset.bound) return;
el.dataset.bound = '1';
if (!el.title) el.title = 'Click to copy';
el.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var pin = this.dataset.pin;
if (pin && navigator.clipboard) {
var textEl = this.querySelector('.mokosuiteclient-pin-text');
navigator.clipboard.writeText(pin).then(function() {
if (textEl) {
textEl.textContent = 'Copied!';
setTimeout(function() { textEl.textContent = pin; }, 5000);
}
});
}
});
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.mokosuiteclient-pin-copy').forEach(bindCopy);
document.querySelectorAll('.mokosuiteclient-pin-request').forEach(function(el) {
if (el.dataset.bound) return;
el.dataset.bound = '1';
if (!el.title) el.title = 'Request a support PIN';
el.addEventListener('click', function(e) {
e.preventDefault();
if (this.dataset.busy) return;
this.dataset.busy = '1';
var btn = this;
var textEl = btn.querySelector('.mokosuiteclient-pin-text');
var origHtml = btn.innerHTML;
if (textEl) { textEl.textContent = '...'; } else { btn.textContent = '...'; }
var fd = new FormData();
fd.append(btn.dataset.token, '1');
fetch(btn.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.pin) {
btn.classList.remove('mokosuiteclient-pin-request');
btn.classList.add('mokosuiteclient-pin-copy');
btn.dataset.pin = data.pin;
btn.title = 'Click to copy';
if (textEl) {
textEl.textContent = data.pin;
} else {
btn.className = 'badge bg-dark mokosuiteclient-pin-copy';
btn.style = 'font-family:monospace;letter-spacing:0.08em;cursor:pointer;';
btn.innerHTML = '<span class="icon-key small me-1" aria-hidden="true"></span><span class="mokosuiteclient-pin-text">' + data.pin + '</span>';
}
btn.dataset.bound = '';
bindCopy(btn);
} else {
Joomla.renderMessages({error: [data.message || 'Failed to generate PIN']});
btn.innerHTML = origHtml;
}
delete btn.dataset.busy;
})
.catch(function() {
Joomla.renderMessages({error: ['Network error']});
btn.innerHTML = origHtml;
delete btn.dataset.busy;
});
});
});
});
})();
</script>
JS;
}
/**
* Request a new PIN: stamps the current time into plugin params and returns the PIN.
*
* @param DatabaseInterface $db Database driver.
*
* @return array{success: bool, pin?: string, message: string}
*/
public static function requestNew(DatabaseInterface $db): array
{
$state = self::getState($db);
if (!$state['available'])
{
return ['success' => false, 'message' => 'Health token not configured.'];
}
$now = time();
$params = $state['params'];
$params['support_pin_requested_at'] = $now;
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
->where($db->quoteName('extension_id') . ' = ' . $state['ext_id']);
$db->setQuery($query)->execute();
$pinHours = (int) ($params['support_pin_hours'] ?? 0) ?: (int) (self::PIN_TTL_DEFAULT / 3600);
$pinTtl = $pinHours * 3600;
$pin = self::generate($state['token'], $now, $pinTtl);
return ['success' => true, 'pin' => $pin, 'message' => 'PIN generated — valid for ' . $pinHours . ' hours.'];
}
}
@@ -77,6 +77,30 @@ class DashboardModel extends BaseDatabaseModel
'protected' => false,
'configure_only' => true,
],
'mokosuiteclient_backup' => [
'icon' => 'icon-archive',
'category' => 'monitoring',
'label' => 'Backup Bridge',
'description' => 'Detects MokoSuiteBackup and sends backup status in heartbeat payloads to HQ.',
'protected' => false,
'configure_only' => true,
],
'mokosuiteclient_dbip' => [
'icon' => 'icon-globe',
'category' => 'security',
'label' => 'GeoIP Lookup',
'description' => 'Country-level IP geolocation using DB-IP lite database for WAF and analytics.',
'protected' => false,
'configure_only' => true,
],
'mokosuiteclient_license' => [
'icon' => 'icon-key',
'category' => 'tools',
'label' => 'License Manager',
'description' => 'Download key management and license validation for MokoSuite packages.',
'protected' => false,
'configure_only' => true,
],
];
/**
@@ -213,30 +237,46 @@ class DashboardModel extends BaseDatabaseModel
}
/**
* Get installed MokoSuiteClient component and modules with versions.
* Discover all installed MokoSuite ecosystem extensions.
*
* @return array Array of extension objects with name, element, type, version.
* Fuzzy-matches packages, components, modules, plugins, and libraries
* by element name containing "mokosuite", "mokosuiteclient", "mokojoom",
* or "moko" prefix patterns.
*
* @return array Extension objects with name, element, type, version, enabled, family.
*/
public function getMokoExtensions(): array
{
$db = $this->getDatabase();
$el = $db->quoteName('element');
// Fuzzy match: any extension whose element contains moko patterns
$patterns = [
$el . ' LIKE ' . $db->quote('pkg_mokosuite%'),
$el . ' LIKE ' . $db->quote('com_mokosuite%'),
$el . ' LIKE ' . $db->quote('mod_mokosuite%'),
$el . ' LIKE ' . $db->quote('mokosuite%'),
$el . ' LIKE ' . $db->quote('mokosuiteclient%'),
$el . ' LIKE ' . $db->quote('pkg_mokojoom%'),
$el . ' LIKE ' . $db->quote('com_mokojoom%'),
$el . ' LIKE ' . $db->quote('mod_mokojoom%'),
$el . ' LIKE ' . $db->quote('mokojoom%'),
$el . ' LIKE ' . $db->quote('plg_%_mokosuite%'),
$el . ' LIKE ' . $db->quote('plg_%_mokojoom%'),
];
$query = $db->getQuery(true)
->select([
$db->quoteName('extension_id'),
$db->quoteName('element'),
$db->quoteName('name'),
$db->quoteName('type'),
$db->quoteName('folder'),
$db->quoteName('enabled'),
$db->quoteName('manifest_cache'),
])
->from($db->quoteName('#__extensions'))
->where('('
// The component
. '(' . $db->quoteName('type') . ' = ' . $db->quote('component')
. ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient') . ')'
// Admin modules
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('module')
. ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mod_mokosuiteclient%') . ')'
. ')')
->where('(' . implode(' OR ', $patterns) . ')')
->order($db->quoteName('type') . ' ASC, ' . $db->quoteName('element') . ' ASC');
$db->setQuery($query);
@@ -248,12 +288,27 @@ class DashboardModel extends BaseDatabaseModel
{
$manifest = json_decode($row->manifest_cache ?? '{}');
// Determine product family from element name
$family = 'mokosuite';
if (stripos($row->element, 'mokosuiteclient') !== false) {
$family = 'mokosuiteclient';
} elseif (stripos($row->element, 'mokosuitehq') !== false) {
$family = 'mokosuitehq';
} elseif (stripos($row->element, 'mokosuitecrm') !== false) {
$family = 'mokosuitecrm';
} elseif (stripos($row->element, 'mokojoom') !== false) {
$family = 'mokojoom';
}
$extensions[] = (object) [
'element' => $row->element,
'name' => $manifest->name ?? $row->name,
'type' => $row->type,
'version' => $manifest->version ?? '',
'enabled' => (int) $row->enabled,
'extension_id' => (int) $row->extension_id,
'element' => $row->element,
'name' => $manifest->name ?? $row->name,
'type' => $row->type,
'folder' => $row->folder ?? '',
'version' => $manifest->version ?? '',
'enabled' => (int) $row->enabled,
'family' => $family,
];
}
@@ -112,7 +112,7 @@ class ExtensionsModel extends BaseDatabaseModel
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$data = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
@@ -221,7 +221,7 @@ class ExtensionsModel extends BaseDatabaseModel
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$response = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
@@ -238,8 +238,15 @@ class ExtensionsModel extends BaseDatabaseModel
return [];
}
// Determine site's update channel preference
$channel = 'dev'; // default to dev — show everything
// Dev channel only available on Moko domains; all others forced to stable
$isMokoDomain = (bool) preg_match('/\.mokoconsulting\.tech$/i', $_SERVER['HTTP_HOST'] ?? '');
$channel = 'stable';
if ($isMokoDomain) {
try {
$channel = \Joomla\CMS\Component\ComponentHelper::getParams('com_installer')
->get('update_channel', 'stable') ?: 'stable';
} catch (\Throwable $e) {}
}
$hasStable = false;
$hasDev = false;
@@ -269,7 +276,18 @@ class ExtensionsModel extends BaseDatabaseModel
$hasDev = true;
}
if ($ver === '' || version_compare($ver, $bestVersion, '<='))
if ($ver === '')
{
continue;
}
// Respect update channel: stable channel skips dev-tagged versions
if ($channel === 'stable' && $tag === 'dev')
{
continue;
}
if (version_compare($ver, $bestVersion, '<='))
{
continue;
}
@@ -1,183 +0,0 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage com_mokosuiteclient
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Filesystem\File;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Log\Log;
class AttachmentService
{
private const STORAGE_DIR = JPATH_ROOT . '/media/com_mokosuiteclient/attachments';
private const ALLOWED_EXTENSIONS = [
'jpg', 'jpeg', 'png', 'gif', 'webp',
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'rtf',
'zip', 'gz', 'tar',
];
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
/**
* Upload file(s) for a ticket or reply.
*
* @param int $ticketId Ticket ID
* @param int|null $replyId Reply ID (null for ticket-level attachments)
* @param array $files $_FILES array entry (single or multi)
* @return array Saved attachment records
*/
public static function upload(int $ticketId, ?int $replyId, array $files): array
{
$saved = [];
// Normalize single file to array format
if (!is_array($files['name'])) {
$files = [
'name' => [$files['name']],
'type' => [$files['type']],
'tmp_name' => [$files['tmp_name']],
'error' => [$files['error']],
'size' => [$files['size']],
];
}
$ticketDir = self::STORAGE_DIR . '/' . $ticketId;
if (!is_dir($ticketDir) && !Folder::create($ticketDir)) {
Log::add("Failed to create attachment directory: {$ticketDir}", Log::ERROR, 'mokosuiteclient');
return [];
}
$userId = (int) Factory::getUser()->id;
$db = Factory::getDbo();
for ($i = 0, $count = count($files['name']); $i < $count; $i++)
{
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
Log::add("Attachment upload error for '{$files['name'][$i]}': PHP error code {$files['error'][$i]}", Log::WARNING, 'mokosuiteclient');
continue;
}
$originalName = File::makeSafe($files['name'][$i]);
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
// Validate extension
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuiteclient');
continue;
}
// Validate size
if ($files['size'][$i] > self::MAX_FILE_SIZE) {
Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuiteclient');
continue;
}
// Generate unique filename to prevent overwrites
$storedName = uniqid('att_', true) . '.' . $ext;
$destPath = $ticketDir . '/' . $storedName;
if (!File::upload($files['tmp_name'][$i], $destPath)) {
Log::add("Attachment upload failed: {$originalName}", Log::ERROR, 'mokosuiteclient');
continue;
}
$record = (object) [
'ticket_id' => $ticketId,
'reply_id' => $replyId,
'filename' => $originalName,
'filepath' => $ticketId . '/' . $storedName,
'filesize' => $files['size'][$i],
'mimetype' => mime_content_type($destPath) ?: 'application/octet-stream',
'uploaded_by' => $userId,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_ticket_attachments', $record, 'id');
$saved[] = $record;
}
return $saved;
}
/**
* Get attachments for a ticket.
*/
public static function getForTicket(int $ticketId): array
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('a.*, u.name AS uploader_name')
->from($db->quoteName('#__mokosuiteclient_ticket_attachments', 'a'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by')
->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId)
->order('a.created ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Get the absolute filesystem path for an attachment.
*/
public static function getAbsolutePath(object $attachment): ?string
{
$path = realpath(self::STORAGE_DIR . '/' . $attachment->filepath);
if ($path === false || !str_starts_with($path, realpath(self::STORAGE_DIR))) {
return null;
}
return $path;
}
/**
* Delete an attachment (file + DB record).
*/
public static function delete(int $attachmentId): bool
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from('#__mokosuiteclient_ticket_attachments')
->where('id = ' . $attachmentId)
);
$att = $db->loadObject();
if (!$att) {
return false;
}
$path = self::STORAGE_DIR . '/' . $att->filepath;
if (file_exists($path)) {
File::delete($path);
}
$db->setQuery(
$db->getQuery(true)
->delete('#__mokosuiteclient_ticket_attachments')
->where('id = ' . $attachmentId)
)->execute();
return true;
}
/**
* Format file size for display.
*/
public static function formatSize(int $bytes): string
{
if ($bytes < 1024) return $bytes . ' B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
return round($bytes / 1048576, 1) . ' MB';
}
}
@@ -1,280 +0,0 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage com_mokosuiteclient
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
/**
* Automation rule engine — evaluates trigger/condition/action rules.
*
* Called from event hooks (system plugin, task plugin) whenever
* a triggering event occurs. Loads matching rules, checks conditions,
* and executes actions.
*
* @since 02.35.00
*/
class AutomationEngine
{
/**
* Fire all matching rules for a given trigger event.
*
* @param string $triggerEvent Event name (ticket_created, user_login, etc.)
* @param array $context Context data (ticket object, user data, etc.)
*/
public static function fire(string $triggerEvent, array $context = []): void
{
try
{
$rules = self::getActiveRules($triggerEvent);
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if (self::evaluateConditions($conditions, $context))
{
self::executeActions($actions, $rule, $context);
}
}
}
catch (\Throwable $e)
{
Log::add('Automation engine error: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
}
}
/**
* Get active automation rules for a trigger event.
*/
private static function getActiveRules(string $event): array
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from('#__mokosuiteclient_ticket_automation')
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
->where($db->quoteName('enabled') . ' = 1')
->order('ordering ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Evaluate all conditions (AND logic).
*/
private static function evaluateConditions(array $conditions, array $context): bool
{
foreach ($conditions as $c)
{
$field = $c['field'] ?? '';
$op = $c['op'] ?? 'eq';
$expected = $c['value'] ?? '';
$actual = $context[$field] ?? '';
switch ($op)
{
case 'eq': if ((string) $actual !== (string) $expected) return false; break;
case 'neq': if ((string) $actual === (string) $expected) return false; break;
case 'gt': if ((float) $actual <= (float) $expected) return false; break;
case 'lt': if ((float) $actual >= (float) $expected) return false; break;
case 'in':
$values = array_map('trim', explode(',', $expected));
if (!in_array((string) $actual, $values, true)) return false;
break;
case 'not_in':
$values = array_map('trim', explode(',', $expected));
if (in_array((string) $actual, $values, true)) return false;
break;
}
}
return true;
}
/**
* Execute actions for a matched rule.
*/
private static function executeActions(array $actions, object $rule, array $context): void
{
$db = Factory::getDbo();
$ticketId = (int) ($context['ticket_id'] ?? $context['id'] ?? 0);
foreach ($actions as $action)
{
$type = $action['type'] ?? '';
$value = $action['value'] ?? '';
try
{
switch ($type)
{
case 'set_status':
if ($ticketId) {
$statusId = self::resolveStatusId($db, $value);
$sets = "status = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}";
if ($statusId) { $sets .= ", status_id = {$statusId}"; }
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
}
break;
case 'set_priority':
if ($ticketId) {
$priorityId = self::resolvePriorityId($db, $value);
$sets = "priority = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}";
if ($priorityId) { $sets .= ", priority_id = {$priorityId}"; }
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
}
break;
case 'assign':
$assignId = (int) $value;
if ($ticketId && $assignId > 0) {
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET assigned_to = {$assignId}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
}
break;
case 'add_note':
if ($ticketId) {
$note = (object) [
'ticket_id' => $ticketId,
'user_id' => 0,
'body' => $value ?: '[Automation: ' . ($rule->title ?? '') . ']',
'is_internal' => 1,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_ticket_replies', $note);
}
break;
case 'send_email':
NotificationService::securityAlert(
'automation',
'Automation: ' . ($rule->title ?? ''),
$value ?: 'Rule triggered for ticket #' . $ticketId
);
break;
case 'send_ntfy':
NotificationService::pushNtfySecurity(
'automation',
'Automation: ' . ($rule->title ?? ''),
$value ?: 'Rule triggered for ticket #' . $ticketId
);
break;
case 'close':
if ($ticketId) {
$closedId = self::resolveClosedStatusId($db);
$sets = "status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}, modified = {$db->quote(Factory::getDate()->toSql())}";
if ($closedId) { $sets .= ", status_id = {$closedId}"; }
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
}
break;
case 'create_ticket':
self::createTicketFromAutomation($rule, $context, $value);
break;
}
}
catch (\Throwable $e)
{
Log::add("Automation action '{$type}' failed for rule #{$rule->id}: " . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
}
}
}
/**
* Create a ticket from automation (with behavior: append/always_new/skip_if_open).
*/
private static function resolveStatusId($db, string $alias): int
{
return (int) $db->setQuery(
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1
)->loadResult();
}
private static function resolvePriorityId($db, string $alias): int
{
return (int) $db->setQuery(
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1
)->loadResult();
}
private static function resolveClosedStatusId($db): int
{
return (int) $db->setQuery(
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
->where($db->quoteName('is_closed') . ' = 1'), 0, 1
)->loadResult();
}
private static function createTicketFromAutomation(object $rule, array $context, string $subject): void
{
$db = Factory::getDbo();
$behavior = $rule->behavior ?? 'append';
$userId = (int) ($context['user_id'] ?? 0);
$catId = (int) ($context['category_id'] ?? 0);
if ($behavior !== 'always_new' && $userId > 0)
{
// Check for existing open ticket (check both status ENUM and status_id)
$query = $db->getQuery(true)
->select('t.id')
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
->join('LEFT', $db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON t.status_id = s.id')
->where('t.created_by = ' . $userId)
->where("(s.id IS NULL AND t.status NOT IN ('closed', 'resolved')) OR (s.id IS NOT NULL AND s.is_closed = 0)");
if ($catId > 0) {
$query->where('category_id = ' . $catId);
}
$db->setQuery($query, 0, 1);
$existingId = (int) $db->loadResult();
if ($existingId > 0)
{
if ($behavior === 'skip_if_open') return;
// append — add reply to existing ticket
$reply = (object) [
'ticket_id' => $existingId,
'user_id' => 0,
'body' => $subject ?: '[Automation: ' . ($rule->title ?? '') . ']',
'is_internal' => 1,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply);
return;
}
}
// Create new ticket
$openStatusId = self::resolveStatusId($db, 'open') ?: null;
$normalPriorityId = self::resolvePriorityId($db, $context['priority'] ?? 'normal') ?: null;
$ticket = (object) [
'subject' => $subject ?: 'Automation: ' . ($rule->title ?? ''),
'body' => $context['body'] ?? '',
'status' => 'open',
'status_id' => $openStatusId,
'priority' => $context['priority'] ?? 'normal',
'priority_id' => $normalPriorityId,
'category_id' => $catId ?: null,
'created_by' => $userId,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
}
}
@@ -1,581 +0,0 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage com_mokosuiteclient
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Uri\Uri;
/**
* Helpdesk email notification service.
*
* Sends emails for ticket events to Joomla users (by ID) and/or
* raw email addresses. Uses Joomla's configured mailer.
*
* @since 02.32.00
*/
class NotificationService
{
/**
* Send a ticket notification email.
*
* @param string $event Event name (ticket_created, ticket_replied, status_changed, ticket_assigned)
* @param object $ticket Ticket object with id, subject, status, priority, created_by, assigned_to
* @param array $extra Extra context (reply body, old status, etc.)
*/
public static function notify(string $event, object $ticket, array $extra = []): void
{
try
{
$recipients = self::getRecipients($event, $ticket);
if (empty($recipients))
{
return;
}
$subject = self::buildSubject($event, $ticket);
$body = self::buildBody($event, $ticket, $extra);
$mailer = Factory::getMailer();
$mailer->isHtml(false);
$mailer->setSubject($subject);
$mailer->setBody($body);
foreach ($recipients as $email)
{
$email = trim($email);
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL))
{
continue;
}
try
{
$mailer->clearAddresses();
$mailer->addRecipient($email);
$mailer->Send();
}
catch (\Throwable $e)
{
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
// Push notification via ntfy
self::pushNtfy($event, $ticket, $subject);
}
catch (\Throwable $e)
{
Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Determine recipients based on event type and ticket data.
*/
private static function getRecipients(string $event, object $ticket): array
{
$emails = [];
// Get notification config from component params
$config = self::getNotificationConfig();
// Always notify configured admin emails
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
$emails = array_merge($emails, $adminEmails);
// Always notify configured admin user IDs
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
foreach ($adminUserIds as $uid)
{
$email = self::getUserEmail($uid);
if ($email)
{
$emails[] = $email;
}
}
switch ($event)
{
case 'ticket_created':
// Notify assigned user if any
if (!empty($ticket->assigned_to))
{
$email = self::getUserEmail((int) $ticket->assigned_to);
if ($email)
{
$emails[] = $email;
}
}
break;
case 'ticket_replied':
// Notify ticket creator (customer gets notified of staff reply)
if (!empty($ticket->created_by))
{
$email = self::getUserEmail((int) $ticket->created_by);
if ($email)
{
$emails[] = $email;
}
}
// Notify assigned user
if (!empty($ticket->assigned_to))
{
$email = self::getUserEmail((int) $ticket->assigned_to);
if ($email)
{
$emails[] = $email;
}
}
break;
case 'status_changed':
// Notify ticket creator
if (!empty($ticket->created_by))
{
$email = self::getUserEmail((int) $ticket->created_by);
if ($email)
{
$emails[] = $email;
}
}
break;
case 'ticket_assigned':
// Notify newly assigned user
if (!empty($ticket->assigned_to))
{
$email = self::getUserEmail((int) $ticket->assigned_to);
if ($email)
{
$emails[] = $email;
}
}
break;
}
return array_unique($emails);
}
/**
* Build email subject line.
*/
private static function buildSubject(string $event, object $ticket): string
{
$siteName = Factory::getConfig()->get('sitename', 'Support');
$prefix = '[' . $siteName . ' #' . $ticket->id . '] ';
switch ($event)
{
case 'ticket_created':
return $prefix . 'New Ticket: ' . ($ticket->subject ?? '');
case 'ticket_replied':
return $prefix . 'Reply: ' . ($ticket->subject ?? '');
case 'status_changed':
return $prefix . 'Status Changed: ' . ($ticket->subject ?? '');
case 'ticket_assigned':
return $prefix . 'Assigned: ' . ($ticket->subject ?? '');
default:
return $prefix . ($ticket->subject ?? '');
}
}
/**
* Build email body.
*/
private static function buildBody(string $event, object $ticket, array $extra): string
{
$siteName = Factory::getConfig()->get('sitename', 'Support');
$siteUrl = rtrim(Uri::root(), '/');
$ticketUrl = $siteUrl . '/index.php?option=com_mokosuiteclient&view=ticket&id=' . $ticket->id;
$lines = [];
$lines[] = $siteName . ' Support';
$lines[] = str_repeat('-', 40);
$lines[] = '';
switch ($event)
{
case 'ticket_created':
$lines[] = 'A new support ticket has been created.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
$lines[] = 'Category: ' . ($ticket->category_title ?? 'General');
$lines[] = '';
if (!empty($ticket->body))
{
$lines[] = 'Description:';
$lines[] = strip_tags($ticket->body);
$lines[] = '';
}
break;
case 'ticket_replied':
$lines[] = 'A new reply has been added to your ticket.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
$lines[] = '';
if (!empty($extra['reply_body']))
{
$lines[] = 'Reply:';
$lines[] = strip_tags($extra['reply_body']);
$lines[] = '';
}
break;
case 'status_changed':
$lines[] = 'Your ticket status has been updated.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'New Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
if (!empty($extra['old_status']))
{
$lines[] = 'Old Status: ' . ucwords(str_replace('_', ' ', $extra['old_status']));
}
$lines[] = '';
break;
case 'ticket_assigned':
$lines[] = 'A ticket has been assigned to you.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
$lines[] = '';
break;
}
$lines[] = 'View ticket: ' . $ticketUrl;
$lines[] = '';
$lines[] = '-- ';
$lines[] = $siteName . ' | Powered by MokoSuiteClient';
return implode("\n", $lines);
}
/**
* Get email address for a Joomla user ID.
*/
private static function getUserEmail(int $userId): ?string
{
if ($userId <= 0)
{
return null;
}
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('email'))
->from($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = ' . $userId)
);
return $db->loadResult() ?: null;
}
catch (\Throwable $e)
{
Log::add('Failed to look up email for user ID ' . $userId . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
return null;
}
}
/**
* Get notification configuration from component params.
*/
private static function getNotificationConfig(): array
{
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$params = json_decode($db->loadResult() ?? '{}', true);
return $params['notifications'] ?? [];
}
catch (\Throwable $e)
{
Log::add('Failed to load notification config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
return [];
}
}
// ==================================================================
// Ntfy Push Notifications (#205)
// ==================================================================
/**
* Send a push notification via ntfy for ticket events.
*/
private static function pushNtfy(string $event, object $ticket, string $title): void
{
$config = self::getNotificationConfig();
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
if (!$ntfyEnabled)
{
return;
}
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
$ntfyTopic = $config['ntfy_topic'] ?? 'mokosuiteclient-tickets';
$ntfyToken = $config['ntfy_token'] ?? '';
$tagMap = [
'ticket_created' => 'ticket,new',
'ticket_replied' => 'speech_balloon',
'status_changed' => 'arrows_counterclockwise',
'ticket_assigned' => 'bust_in_silhouette',
];
$priorityMap = [
'ticket_created' => '4',
'ticket_replied' => '3',
'status_changed' => '3',
'ticket_assigned' => '3',
];
$siteUrl = rtrim(Uri::root(), '/');
$ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuiteclient&view=ticket&id=' . ($ticket->id ?? 0);
$message = self::buildNtfyMessage($event, $ticket);
$headers = [
'Title: ' . $title,
'Priority: ' . ($priorityMap[$event] ?? '3'),
'Tags: ' . ($tagMap[$event] ?? 'ticket'),
'Click: ' . $ticketUrl,
];
if ($ntfyToken !== '')
{
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
}
$url = $ntfyServer . '/' . $ntfyTopic;
try
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false)
{
Log::add("Ntfy push connection failed for event {$event}: " . $curlError, Log::WARNING, 'mokosuiteclient');
}
elseif ($httpCode < 200 || $httpCode >= 300)
{
Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuiteclient');
}
}
catch (\Throwable $e)
{
Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Build a short ntfy message body for ticket events.
*/
private static function buildNtfyMessage(string $event, object $ticket): string
{
$subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?');
switch ($event)
{
case 'ticket_created':
$priority = ucfirst($ticket->priority ?? 'normal');
return "New ticket: {$subject}\nPriority: {$priority}";
case 'ticket_replied':
return "Reply on: {$subject}";
case 'status_changed':
$status = ucwords(str_replace('_', ' ', $ticket->status ?? ''));
return "Status → {$status}: {$subject}";
case 'ticket_assigned':
return "Assigned to you: {$subject}";
default:
return $subject;
}
}
/**
* Send a push notification via ntfy for security events.
*/
public static function pushNtfySecurity(string $event, string $title, string $body): void
{
$config = self::getNotificationConfig();
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
if (!$ntfyEnabled)
{
return;
}
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
$ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuiteclient-security';
$ntfyToken = $config['ntfy_token'] ?? '';
$headers = [
'Title: [Security] ' . $title,
'Priority: 5',
'Tags: warning,shield',
];
if ($ntfyToken !== '')
{
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
}
$url = $ntfyServer . '/' . $ntfyTopic;
try
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_exec($ch);
curl_close($ch);
}
catch (\Throwable $e)
{
Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
// ==================================================================
// Security Event Notifications (#131)
// ==================================================================
/**
* Send a security alert to admin emails.
*/
public static function securityAlert(string $event, string $subject, string $body): void
{
try
{
$config = self::getNotificationConfig();
$enabled = $config['security_alerts'] ?? '1';
if (!$enabled)
{
return;
}
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
$recipients = $adminEmails;
foreach ($adminUserIds as $uid)
{
$email = self::getUserEmail($uid);
if ($email)
{
$recipients[] = $email;
}
}
$recipients = array_unique($recipients);
if (empty($recipients))
{
return;
}
$siteName = Factory::getConfig()->get('sitename', 'Site');
$fullSubject = '[' . $siteName . ' Security] ' . $subject;
$lines = [
$siteName . ' Security Alert',
str_repeat('-', 40),
'',
'Event: ' . $event,
'Time: ' . gmdate('Y-m-d H:i:s') . ' UTC',
'',
$body,
'',
'-- ',
$siteName . ' | MokoSuiteClient Security',
];
$mailer = Factory::getMailer();
$mailer->isHtml(false);
$mailer->setSubject($fullSubject);
$mailer->setBody(implode("\n", $lines));
foreach ($recipients as $email)
{
try
{
$mailer->clearAddresses();
$mailer->addRecipient(trim($email));
$mailer->Send();
}
catch (\Throwable $e)
{
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
// Also push via ntfy
self::pushNtfySecurity($event, $subject, $body);
}
catch (\Throwable $e)
{
Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
}
@@ -18,6 +18,7 @@ class HtmlView extends BaseHtmlView
ToolbarHelper::title('Automation Rules', 'cogs');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Automation');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
@@ -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);
}
}
@@ -32,6 +32,7 @@ class HtmlView extends BaseHtmlView
ToolbarHelper::title('Ticket Categories', 'folder');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Categories');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
@@ -18,6 +18,7 @@ class HtmlView extends BaseHtmlView
ToolbarHelper::title('Cache &amp; Temp Cleanup', 'trash');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Cleanup');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
@@ -27,6 +27,8 @@ class HtmlView extends BaseHtmlView
protected $loginChartData = [];
protected $mokoExtensions = [];
public $supportPin = '';
public $supportPinAvailable = false;
public $regularLabsAvailable = false;
public function display($tpl = null)
{
@@ -36,26 +38,24 @@ class HtmlView extends BaseHtmlView
$this->siteInfo = $model->getSiteInfo();
// Daily support PIN from health token
try
{
$db = \Joomla\CMS\Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
);
$token = (json_decode((string) $db->loadResult()))->health_api_token ?? '';
$pinState = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::getState(
\Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class)
);
$this->supportPinAvailable = $pinState['available'];
$this->supportPin = $pinState['pin'];
// Detect Regular Labs data for import (source table must exist AND our destination table)
try {
$rlDb = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$rlTables = $rlDb->getTableList();
$rlPrefix = $rlDb->getPrefix();
$this->regularLabsAvailable =
(in_array($rlPrefix . 'conditions', $rlTables) && in_array($rlPrefix . 'mokosuiteclient_conditions', $rlTables))
|| (in_array($rlPrefix . 'snippets', $rlTables) && in_array($rlPrefix . 'mokosuiteclient_snippets', $rlTables))
|| (in_array($rlPrefix . 'rereplacer', $rlTables) && in_array($rlPrefix . 'mokosuiteclient_replacements', $rlTables))
|| (in_array($rlPrefix . 'contenttemplater', $rlTables) && in_array($rlPrefix . 'mokosuiteclient_content_templates', $rlTables));
} catch (\Throwable $e) {}
if (!empty($token))
{
$hash = hash_hmac('sha256', gmdate('Y-m-d'), $token);
$this->supportPin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
}
}
catch (\Throwable $e) {}
$this->recentLogins = $model->getRecentLogins(10);
$this->pendingUpdates = $model->getPendingUpdates();
$this->checkedOutItems = $model->getCheckedOutItems();
@@ -96,5 +96,7 @@ class HtmlView extends BaseHtmlView
{
ToolbarHelper::preferences('com_mokosuiteclient');
}
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Dashboard');
}
}
@@ -18,6 +18,7 @@ class HtmlView extends BaseHtmlView
ToolbarHelper::title('Database Tools', 'database');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Database');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
@@ -31,6 +31,7 @@ class HtmlView extends BaseHtmlView
$this->pipelineData = $model->getPipelineReport($this->dateFrom, $this->dateTo);
$this->agingData = $model->getAgingReceivables();
ToolbarHelper::title('ERP Reports', 'icon-chart-bar');
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/ERP-Reports');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokosuiteclient.erp', 'com_mokosuiteclient/erp.css');
$wa->registerAndUseScript('com_mokosuiteclient.erp-dashboard', 'com_mokosuiteclient/erp-dashboard.js', [], ['defer' => true]);
@@ -37,5 +37,6 @@ class HtmlView extends BaseHtmlView
{
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_EXTENSIONS_TITLE'), 'puzzle-piece');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Extensions');
}
}
@@ -43,5 +43,6 @@ class HtmlView extends BaseHtmlView
{
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_HTACCESS_TITLE'), 'file-code');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Htaccess');
}
}
@@ -35,5 +35,6 @@ class HtmlView extends BaseHtmlView
{
ToolbarHelper::title('Privacy Guard', 'lock');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Privacy');
}
}
@@ -51,5 +51,6 @@ class HtmlView extends BaseHtmlView
{
ToolbarHelper::title('WAF Log Viewer', 'shield-alt');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/WAF-Log');
}
}
@@ -1,227 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$responses = $this->responses;
$categories = $this->categories;
$token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveCanned&format=json');
$deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteCanned&format=json');
$reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorderCanned&format=json');
// Build category map for filter display
$catMap = [0 => 'All Categories'];
foreach ($categories as $cat)
{
$catMap[$cat->id] = $cat->title;
}
?>
<div id="mokosuiteclient-canned">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-3">
<h4 class="mb-0"><?php echo count($responses); ?> Canned Responses</h4>
<select id="canned-filter-category" class="form-select form-select-sm" style="width:auto;">
<option value="">All Categories</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="button" class="btn btn-primary btn-sm" onclick="openCannedModal(0)">
<span class="icon-plus"></span> Add Response
</button>
</div>
<div id="canned-list">
<?php foreach ($responses as $r): ?>
<div class="card mb-2 canned-card" data-id="<?php echo $r->id; ?>" data-category="<?php echo (int) $r->category_id; ?>" style="cursor:grab;">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1" style="cursor:pointer;" onclick="openCannedModal(<?php echo $r->id; ?>)">
<div class="d-flex align-items-center gap-2">
<span class="icon-menu text-muted" style="cursor:grab;" title="Drag to reorder"></span>
<strong><?php echo htmlspecialchars($r->title); ?></strong>
<?php if (!empty($r->category_id) && isset($catMap[$r->category_id])): ?>
<span class="badge bg-secondary"><?php echo htmlspecialchars($catMap[$r->category_id]); ?></span>
<?php endif; ?>
</div>
<p class="text-muted small mb-0 mt-1 ms-4"><?php echo htmlspecialchars(mb_substr(strip_tags($r->body), 0, 150)); ?></p>
</div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
<span class="icon-trash"></span>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($responses)): ?>
<div class="alert alert-info" id="canned-empty">No canned responses yet. Click "Add Response" to create one.</div>
<?php endif; ?>
</div>
</div>
<!-- Canned Response Modal (create + edit) -->
<div class="modal fade" id="cannedModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 id="cannedModalTitle">Add Canned Response</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="canned-id" value="0">
<div class="mb-3">
<label class="form-label">Title</label>
<input type="text" id="canned-title" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Category (optional)</label>
<select id="canned-category" class="form-select">
<option value="">No category</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Response Text</label>
<textarea id="canned-body" class="form-control" rows="8" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-save-canned"><span class="icon-save"></span> Save</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var tokenKey = '<?php echo $token; ?>';
// ── Response data store (for edit modal) ────────────────────
var responseData = {};
<?php foreach ($responses as $r): ?>
responseData[<?php echo $r->id; ?>] = {
title: <?php echo json_encode($r->title); ?>,
body: <?php echo json_encode($r->body); ?>,
category_id: <?php echo json_encode($r->category_id ?? ''); ?>
};
<?php endforeach; ?>
// ── Open modal for create (id=0) or edit ────────────────────
window.openCannedModal = function(id) {
document.getElementById('canned-id').value = id;
if (id > 0 && responseData[id]) {
document.getElementById('cannedModalTitle').textContent = 'Edit Canned Response';
document.getElementById('canned-title').value = responseData[id].title;
document.getElementById('canned-body').value = responseData[id].body;
document.getElementById('canned-category').value = responseData[id].category_id || '';
} else {
document.getElementById('cannedModalTitle').textContent = 'Add Canned Response';
document.getElementById('canned-title').value = '';
document.getElementById('canned-body').value = '';
document.getElementById('canned-category').value = '';
}
new bootstrap.Modal(document.getElementById('cannedModal')).show();
};
// ── Save (create or update) ─────────────────────────────────
document.getElementById('btn-save-canned').addEventListener('click', function() {
var title = document.getElementById('canned-title').value.trim();
if (!title) { Joomla.renderMessages({error:['Title is required']}); return; }
var fd = new FormData();
fd.append('id', document.getElementById('canned-id').value);
fd.append('title', title);
fd.append('body', document.getElementById('canned-body').value);
fd.append('category_id', document.getElementById('canned-category').value);
fd.append(tokenKey, '1');
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) location.reload();
else Joomla.renderMessages({error:[d.message]});
});
});
// ── Delete ──────────────────────────────────────────────────
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
if (!confirm('Delete this canned response?')) return;
var card = this.closest('.card');
var fd = new FormData();
fd.append('id', this.dataset.id);
fd.append(tokenKey, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
});
});
// ── Category filter ─────────────────────────────────────────
document.getElementById('canned-filter-category').addEventListener('change', function() {
var catId = this.value;
document.querySelectorAll('.canned-card').forEach(function(card) {
if (!catId || card.dataset.category === catId) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
});
// ── Drag-and-drop reorder ───────────────────────────────────
var list = document.getElementById('canned-list');
var dragCard = null;
list.addEventListener('dragstart', function(e) {
dragCard = e.target.closest('.canned-card');
if (dragCard) {
dragCard.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
}
});
list.addEventListener('dragend', function() {
if (dragCard) dragCard.style.opacity = '';
dragCard = null;
});
list.addEventListener('dragover', function(e) {
e.preventDefault();
var target = e.target.closest('.canned-card');
if (target && target !== dragCard) {
var rect = target.getBoundingClientRect();
var after = (e.clientY - rect.top) > rect.height / 2;
if (after) {
target.parentNode.insertBefore(dragCard, target.nextSibling);
} else {
target.parentNode.insertBefore(dragCard, target);
}
}
});
list.addEventListener('drop', function(e) {
e.preventDefault();
// Persist new order
var ids = [];
document.querySelectorAll('.canned-card').forEach(function(c) { ids.push(c.dataset.id); });
var fd = new FormData();
fd.append('order', JSON.stringify(ids));
fd.append(tokenKey, '1');
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
});
// Make cards draggable
document.querySelectorAll('.canned-card').forEach(function(card) {
card.setAttribute('draggable', 'true');
});
});
</script>
@@ -25,6 +25,9 @@ $atsAvail = $this->atsAvailable ?? null;
$checkedOut = $this->checkedOutItems;
$wafBlocks = $this->wafBlocks;
$token = Session::getFormToken();
$user = \Joomla\CMS\Factory::getApplication()->getIdentity();
$canWafLog = $user->authorise('mokosuiteclient.security.waflog', 'com_mokosuiteclient')
|| $user->authorise('core.admin', 'com_mokosuiteclient');
// Group plugins by category
$grouped = [];
@@ -53,8 +56,11 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.1rem;color:#1a2744"></span>
<span class="fw-bold"><?php echo $this->escape($siteInfo->sitename); ?></span>
<span class="badge bg-primary">MokoSuite <?php echo $this->escape($siteInfo->mokosuiteclient_version); ?></span>
<?php echo \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderBadge(
['available' => !empty($this->supportPinAvailable), 'pin' => $this->supportPin ?? ''],
$token, 'dashboard'
); ?>
<?php if (!empty($this->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 $this->escape($this->supportPin); ?></span>
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-1" id="mokosuiteclient-btn-heartbeat-pin"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.sendHeartbeat&format=json'); ?>"
data-token="<?php echo $token; ?>"
@@ -79,25 +85,29 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
</div>
<?php if ($adminToolsAvail || $atsAvail): ?>
<?php if ($adminToolsAvail): ?>
<!-- Akeeba Import Banner -->
<div class="alert alert-info d-flex flex-wrap align-items-center gap-3 mb-4">
<span class="icon-info-circle" style="font-size:1.25rem"></span>
<strong>Akeeba data detected — import into MokoSuiteClient:</strong>
<?php if ($adminToolsAvail): ?>
<strong>Akeeba data detected:</strong>
<button type="button" class="btn btn-sm btn-info" id="btn-import-admintools"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.importAdminTools&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-shield-alt"></span> Import Admin Tools Settings
</button>
<?php endif; ?>
<?php if ($atsAvail): ?>
<button type="button" class="btn btn-sm btn-info" id="btn-import-ats-dash"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.importAts&format=json'); ?>"
</div>
<?php endif; ?>
<?php if ($this->regularLabsAvailable): ?>
<!-- Regular Labs Import Banner -->
<div class="alert alert-warning d-flex flex-wrap align-items-center gap-3 mb-4">
<span class="icon-info-circle" style="font-size:1.25rem"></span>
<strong>Regular Labs data detected — import into MokoSuite:</strong>
<button type="button" class="btn btn-sm btn-warning text-dark" id="btn-import-regularlabs"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.importRegularLabs&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-headphones"></span> Import Tickets (<?php echo $atsAvail->tickets; ?> tickets)
<span class="icon-download"></span> Import Conditions, Snippets, Replacements &amp; Templates
</button>
<?php endif; ?>
</div>
<?php endif; ?>
@@ -111,6 +121,14 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
Clear Cache
</button>
</div>
<?php if (\Joomla\CMS\Component\ComponentHelper::isEnabled('com_mokosuitebackup')): ?>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup'); ?>" class="btn btn-outline-primary w-100 py-3">
<span class="icon-archive d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
MokoSuiteBackup
</a>
</div>
<?php endif; ?>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary w-100 py-3">
<span class="icon-refresh d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
@@ -195,11 +213,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
<?php if ($plugin->protected): ?>
<span class="badge bg-dark"><?php echo Text::_('COM_MOKOSUITECLIENT_PROTECTED'); ?></span>
<?php elseif ($plugin->configure_only): ?>
<span class="badge bg-<?php echo $plugin->enabled ? 'success' : 'secondary'; ?>">
<?php echo $plugin->enabled ? Text::_('COM_MOKOSUITECLIENT_ENABLED') : Text::_('COM_MOKOSUITECLIENT_DISABLED'); ?>
</span>
<?php else: ?>
<?php elseif ($plugin->extension_id): ?>
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input mokosuiteclient-toggle" role="switch"
id="toggle-<?php echo $plugin->extension_id; ?>"
@@ -212,7 +226,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
</label>
</div>
<?php endif; ?>
<?php if ($plugin->type === 'plugin'): ?>
<?php if ($plugin->extension_id && $plugin->type === 'plugin'): ?>
<a href="<?php echo Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . $plugin->extension_id); ?>" class="btn btn-sm btn-outline-secondary">
<span class="icon-cog" aria-hidden="true"></span> <?php echo Text::_('COM_MOKOSUITECLIENT_CONFIGURE'); ?>
</a>
@@ -229,6 +243,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
<!-- Right: Charts & Information (4 cols) -->
<div class="col-12 col-xl-4" style="border-left:1px solid var(--gray-300, #dee2e6);padding-left:1.5rem;">
<?php if ($canWafLog): ?>
<!-- WAF Activity Chart -->
<div class="card mb-3">
<div class="card-header">
@@ -238,6 +253,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
<canvas id="mokosuiteclient-chart-waf" height="140"></canvas>
</div>
</div>
<?php endif; ?>
<!-- Login Activity Chart -->
<div class="card mb-3">
@@ -308,6 +324,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
<?php endif; ?>
</div>
<?php if ($canWafLog): ?>
<!-- WAF Blocks -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
@@ -335,6 +352,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Recent Logins -->
<div class="card mb-3">
@@ -445,3 +463,5 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
</script>
<?php echo \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderScript(); ?>
@@ -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();
}
}
@@ -144,6 +144,37 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
// Regular Labs import
var rlBtn = document.getElementById('btn-import-regularlabs');
if (rlBtn) {
rlBtn.addEventListener('click', function () {
var btn = this;
if (!confirm('Import Regular Labs data (conditions, snippets, replacements, templates) into MokoSuite?')) return;
btn.disabled = true;
var origText = btn.textContent;
btn.textContent = ' Importing...';
var fd = new FormData();
fd.append(btn.dataset.token, '1');
fetch(btn.dataset.url, {method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.success) {
Joomla.renderMessages({message: [d.message]});
setTimeout(function () { location.reload(); }, 2000);
} else {
Joomla.renderMessages({error: [d.message]});
btn.disabled = false;
btn.textContent = origText;
}
})
.catch(function () {
Joomla.renderMessages({error: ['Network error']});
btn.disabled = false;
btn.textContent = origText;
});
});
}
// Akeeba import buttons
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
var btn = document.getElementById(id);
@@ -20,7 +20,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>MokoSuiteClient admin dashboard and REST API. Provides a control panel for managing MokoSuiteClient feature plugins, site health monitoring, and remote management endpoints.</description>
<namespace path="src">Moko\Component\MokoSuiteClient</namespace>
@@ -42,7 +42,6 @@
<submenu>
<menu link="option=com_mokosuiteclient" img="class:cogs">COM_MOKOSUITECLIENT_MENU_DASHBOARD</menu>
<menu link="option=com_mokosuiteclient&amp;view=extensions" img="class:puzzle-piece">COM_MOKOSUITECLIENT_MENU_EXTENSIONS</menu>
<menu link="option=com_mokosuiteclient&amp;view=tickets" img="class:headphones">COM_MOKOSUITECLIENT_MENU_TICKETS</menu>
<menu link="option=com_mokosuiteclient&amp;view=htaccess" img="class:file-code">COM_MOKOSUITECLIENT_MENU_HTACCESS</menu>
<menu link="option=com_mokosuiteclient&amp;view=privacy" img="class:lock">COM_MOKOSUITECLIENT_MENU_PRIVACY</menu>
<menu link="option=com_mokosuiteclient&amp;view=waflog" img="class:shield-alt">COM_MOKOSUITECLIENT_MENU_WAFLOG</menu>
@@ -75,10 +74,6 @@
<folder>tmpl</folder>
</files>
<install>
<sql><file driver="mysql" charset="utf8">admin/sql/install.mysql.sql</file></sql>
</install>
<api>
<files folder="api">
<folder>src</folder>
@@ -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'); ?>
&middot; <?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?>
&middot; <?php echo ucfirst($t->priority); ?>
<?php if ($isStaff): ?>
&middot; By: <?php echo htmlspecialchars($t->created_by_name); ?>
<?php endif; ?>
</small>
</div>
<span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?> fs-6">
<?php echo $statusLabel[$t->status] ?? $t->status; ?>
</span>
</div>
</div>
</div>
<!-- Original message -->
<div class="card mb-3">
<div class="card-header">
<strong><?php echo htmlspecialchars($t->created_by_name); ?></strong>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
</div>
<div class="card-body"><?php echo nl2br(htmlspecialchars($t->body)); ?></div>
</div>
<!-- Replies -->
<?php foreach ($t->replies as $reply): ?>
<?php
$replyIsStaffUser = ((int) $reply->user_id !== (int) $t->created_by);
$isInternal = (int) $reply->is_internal;
?>
<div class="card mb-3 <?php echo $isInternal ? 'border-warning bg-warning bg-opacity-10' : ($replyIsStaffUser ? 'border-primary' : ''); ?>">
<div class="card-header d-flex justify-content-between">
<div>
<strong><?php echo htmlspecialchars($reply->user_name ?? 'Support'); ?></strong>
<?php if ($replyIsStaffUser): ?><span class="badge bg-primary ms-1">Staff</span><?php endif; ?>
<?php if ($isInternal): ?><span class="badge bg-warning text-dark ms-1">Internal Note</span><?php endif; ?>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
</div>
</div>
<div class="card-body"><?php echo nl2br(htmlspecialchars($reply->body)); ?></div>
</div>
<?php endforeach; ?>
<!-- Reply form -->
<?php if (!\in_array($t->status, ['closed'])): ?>
<div class="card mt-4">
<div class="card-body">
<h5>Reply</h5>
<form id="portalReply">
<textarea name="body" class="form-control mb-3" rows="5" required placeholder="Type your reply..."></textarea>
<input type="hidden" name="ticket_id" value="<?php echo $t->id; ?>">
<input type="hidden" name="<?php echo $token; ?>" value="1">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<span class="icon-paper-plane"></span> Send Reply
</button>
<?php if ($isStaff): ?>
<button type="button" class="btn btn-outline-warning" id="btn-internal-note">
<span class="icon-eye-slash"></span> Internal Note
</button>
<?php endif; ?>
</div>
</form>
</div>
</div>
<?php elseif ($t->status === 'closed'): ?>
<div class="alert alert-secondary mt-4">
This ticket is closed. <a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=tickets&layout=submit'); ?>">Open a new ticket</a> if you need further help.
</div>
<?php endif; ?>
</div>
<!-- Staff sidebar -->
<?php if ($isStaff): ?>
<div class="col-12 col-lg-4">
<!-- Ticket info -->
<div class="card mb-3">
<div class="card-header"><strong>Details</strong></div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 text-muted">Status</dt>
<dd class="col-7"><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></dd>
<dt class="col-5 text-muted">Priority</dt>
<dd class="col-7"><?php echo ucfirst($t->priority); ?></dd>
<dt class="col-5 text-muted">Category</dt>
<dd class="col-7"><?php echo htmlspecialchars($t->category_title ?? '—'); ?></dd>
<dt class="col-5 text-muted">Submitted By</dt>
<dd class="col-7"><?php echo htmlspecialchars($t->created_by_name); ?><br><small class="text-muted"><?php echo htmlspecialchars($t->created_by_email ?? ''); ?></small></dd>
<dt class="col-5 text-muted">Assigned To</dt>
<dd class="col-7"><?php echo htmlspecialchars($t->assigned_to_name ?? 'Unassigned'); ?></dd>
<dt class="col-5 text-muted">Created</dt>
<dd class="col-7"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></dd>
<dt class="col-5 text-muted">Replies</dt>
<dd class="col-7"><?php echo \count($t->replies); ?></dd>
</dl>
</div>
</div>
<!-- Status actions -->
<div class="card mb-3">
<div class="card-header"><strong>Change Status</strong></div>
<div class="card-body d-grid gap-2">
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
<?php if ($s !== $t->status): ?>
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
data-status="<?php echo $s; ?>">
<?php echo $label; ?>
</button>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php if ($canAssign): ?>
<!-- Quick assign -->
<div class="card mb-3">
<div class="card-header"><strong>Assign</strong></div>
<div class="card-body">
<button type="button" class="btn btn-sm btn-outline-primary w-100" id="btn-assign-me">
Assign to Me
</button>
</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var token = '<?php echo $token; ?>';
var ticketId = <?php echo $t->id; ?>;
// Reply
var replyForm = document.getElementById('portalReply');
if (replyForm) {
replyForm.addEventListener('submit', function(e) {
e.preventDefault();
sendReply(false);
});
}
// Internal note
var internalBtn = document.getElementById('btn-internal-note');
if (internalBtn) {
internalBtn.addEventListener('click', function() { sendReply(true); });
}
function sendReply(isInternal) {
var body = replyForm.querySelector('textarea[name=body]').value.trim();
if (!body) return;
var fd = new FormData();
fd.append('ticket_id', ticketId);
fd.append('body', body);
fd.append('is_internal', isInternal ? '1' : '0');
fd.append(token, '1');
fetch('<?php echo Route::_("index.php?option=com_mokosuiteclient&task=display.submitReply&format=json"); ?>', {
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d){
if (d.success) location.reload();
else alert(d.message);
});
}
// Status buttons
document.querySelectorAll('.btn-status').forEach(function(btn) {
btn.addEventListener('click', function() {
var fd = new FormData();
fd.append('ticket_id', ticketId);
fd.append('status', this.dataset.status);
fd.append(token, '1');
fetch('<?php echo Route::_("index.php?option=com_mokosuiteclient&task=display.updateStatus&format=json"); ?>', {
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d){
if (d.success) location.reload();
else alert(d.message);
});
});
});
// Assign to me
var assignBtn = document.getElementById('btn-assign-me');
if (assignBtn) {
assignBtn.addEventListener('click', function() {
var fd = new FormData();
fd.append('ticket_id', ticketId);
fd.append('assigned_to', <?php echo $userId; ?>);
fd.append(token, '1');
fetch('<?php echo Route::_("index.php?option=com_mokosuiteclient&task=display.assignTicket&format=json"); ?>', {
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d){
if (d.success) location.reload();
else alert(d.message);
});
});
}
});
</script>
@@ -1,83 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$tickets = $this->tickets;
$categories = $this->categories;
$isStaff = $this->isStaff;
$token = Session::getFormToken();
$statusLabel = [
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
'resolved' => 'Resolved', 'closed' => 'Closed',
];
$statusClass = [
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
'resolved' => 'success', 'closed' => 'secondary',
];
?>
<div class="mokosuiteclient-portal">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><?php echo $isStaff ? 'All Support Tickets' : 'My Support Tickets'; ?></h2>
<div class="d-flex gap-2">
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=tickets&layout=submit'); ?>" class="btn btn-primary">
<span class="icon-plus"></span> New Ticket
</a>
<?php if ($isStaff): ?>
<form method="get" class="d-inline">
<input type="hidden" name="option" value="com_mokosuiteclient">
<input type="hidden" name="view" value="tickets">
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All Statuses</option>
<?php foreach ($statusLabel as $k => $v): ?>
<option value="<?php echo $k; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $k ? 'selected' : ''; ?>><?php echo $v; ?></option>
<?php endforeach; ?>
</select>
</form>
<?php endif; ?>
</div>
</div>
<?php if (empty($tickets)): ?>
<div class="alert alert-info">
<span class="icon-info-circle"></span>
<?php echo $isStaff ? 'No tickets found.' : 'You haven\'t submitted any support tickets yet.'; ?>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>#</th>
<th>Subject</th>
<th>Status</th>
<th>Priority</th>
<th>Category</th>
<?php if ($isStaff): ?><th>Submitted By</th><th>Assigned To</th><?php endif; ?>
<th>Date</th>
</tr>
</thead>
<tbody>
<?php foreach ($tickets as $t): ?>
<tr>
<td><a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
<td><a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=' . $t->id); ?>"><?php echo htmlspecialchars(mb_substr($t->subject, 0, 60)); ?></a></td>
<td><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></td>
<td><?php echo ucfirst($t->priority); ?></td>
<td><?php echo htmlspecialchars($t->category_title ?? '—'); ?></td>
<?php if ($isStaff): ?>
<td><?php echo htmlspecialchars($t->created_by_name ?? ''); ?></td>
<td><?php echo htmlspecialchars($t->assigned_to_name ?? '<em>Unassigned</em>'); ?></td>
<?php endif; ?>
<td class="text-nowrap"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
@@ -1,204 +0,0 @@
<?php
/**
* Submit a Ticket layout — search KB first, then submit form.
*/
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$categories = $this->categories;
$token = Session::getFormToken();
$searchUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.searchKb&format=json');
$submitUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.submitTicket&format=json');
$ticketUrl = Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=');
$ticketsUrl = Route::_('index.php?option=com_mokosuiteclient&view=tickets');
// Check if Smart Search has indexed content
$finderEnabled = false;
try {
$db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1');
$finderEnabled = (int) $db->loadResult() > 0;
} catch (\Throwable $e) {}
?>
<div class="mokosuiteclient-portal">
<h2>Submit a Support Request</h2>
<?php if ($finderEnabled): ?>
<!-- Step 1: Search -->
<div id="step-search" class="mb-4">
<p class="text-muted">Before submitting, let's see if we already have an answer for you.</p>
<div class="card">
<div class="card-body">
<label class="form-label fw-bold" for="kb-search">Describe your issue</label>
<div class="input-group input-group-lg">
<input type="text" id="kb-search" class="form-control" placeholder="e.g. how do I reset my password?" autofocus>
<button type="button" class="btn btn-primary" id="kb-search-btn">
<span class="icon-search"></span> Search
</button>
</div>
</div>
</div>
<!-- Search results -->
<div id="kb-results" class="mt-3 d-none">
<h5>Related Articles</h5>
<div id="kb-results-list" class="list-group mb-3"></div>
<p class="text-muted">Didn't find what you need?</p>
</div>
<div class="mt-3">
<button type="button" class="btn btn-outline-primary" id="btn-show-form">
<span class="icon-plus"></span> Submit a Ticket Anyway
</button>
</div>
</div>
<?php endif; ?>
<!-- Step 2: Ticket Form -->
<div id="step-form" class="<?php echo $finderEnabled ? 'd-none' : ''; ?>">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-3">Ticket Details</h5>
<form id="submitTicketForm">
<div class="mb-3">
<label class="form-label" for="ticket-subject">Subject <span class="text-danger">*</span></label>
<input type="text" id="ticket-subject" name="subject" class="form-control" required placeholder="Brief description of your issue">
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label" for="ticket-category">Category</label>
<select id="ticket-category" name="category_id" class="form-select">
<option value="">Select a category</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="ticket-priority">Priority</label>
<select id="ticket-priority" name="priority" class="form-select">
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="ticket-body">Description <span class="text-danger">*</span></label>
<textarea id="ticket-body" name="body" class="form-control" rows="8" required placeholder="Please describe your issue in detail."></textarea>
</div>
<input type="hidden" name="<?php echo $token; ?>" value="1">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<span class="icon-paper-plane"></span> Submit Ticket
</button>
<a href="<?php echo $ticketsUrl; ?>" class="btn btn-outline-secondary btn-lg">
My Tickets
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var searchInput = document.getElementById('kb-search');
var searchBtn = document.getElementById('kb-search-btn');
var resultBox = document.getElementById('kb-results');
var resultList = document.getElementById('kb-results-list');
var showFormBtn = document.getElementById('btn-show-form');
var stepSearch = document.getElementById('step-search');
var stepForm = document.getElementById('step-form');
var subjectField = document.getElementById('ticket-subject');
// Search
function doSearch() {
var q = (searchInput ? searchInput.value.trim() : '');
if (q.length < 3) return;
fetch('<?php echo $searchUrl; ?>&q=' + encodeURIComponent(q), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
resultList.textContent = '';
if (d.results && d.results.length > 0) {
d.results.forEach(function(item) {
var a = document.createElement('a');
a.href = item.url;
a.target = '_blank';
a.className = 'list-group-item list-group-item-action';
var strong = document.createElement('strong');
strong.textContent = item.title;
a.appendChild(strong);
if (item.description) {
a.appendChild(document.createElement('br'));
var small = document.createElement('small');
small.className = 'text-muted';
small.textContent = item.description;
a.appendChild(small);
}
resultList.appendChild(a);
});
resultBox.classList.remove('d-none');
} else {
resultBox.classList.add('d-none');
}
// Always show the "submit anyway" button after search
if (showFormBtn) showFormBtn.classList.remove('d-none');
});
}
if (searchBtn) searchBtn.addEventListener('click', doSearch);
if (searchInput) {
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
});
}
// Show form and prefill subject from search query
if (showFormBtn) {
showFormBtn.addEventListener('click', function() {
if (stepSearch) stepSearch.classList.add('d-none');
if (stepForm) stepForm.classList.remove('d-none');
if (searchInput && subjectField && !subjectField.value) {
subjectField.value = searchInput.value;
}
subjectField.focus();
});
}
// Submit ticket
var form = document.getElementById('submitTicketForm');
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
var btn = form.querySelector('button[type=submit]');
btn.disabled = true;
btn.textContent = ' Submitting...';
var fd = new FormData(form);
fetch('<?php echo $submitUrl; ?>', {
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.id) {
window.location.href = '<?php echo $ticketUrl; ?>' + d.id;
} else {
alert(d.message || 'Failed.');
btn.disabled = false;
btn.textContent = ' Submit Ticket';
}
})
.catch(function() { alert('Network error.'); btn.disabled = false; });
});
}
});
</script>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>MOD_MOKOSUITECLIENT_CACHE_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteClientCache</namespace>
@@ -1,17 +1,34 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage mod_mokosuiteclient_cache
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Moko\Module\MokoSuiteClientCache\Administrator\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
use Joomla\CMS\Factory;
use Joomla\CMS\Uri\Uri;
use Joomla\Database\DatabaseInterface;
class Dispatcher extends AbstractModuleDispatcher
{
protected function getLayoutData()
{
$data = parent::getLayoutData();
$data['domain'] = parse_url(Uri::root(), PHP_URL_HOST) ?: '';
$db = Factory::getContainer()->get(DatabaseInterface::class);
$pinState = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::getState($db);
$data['supportPinAvailable'] = $pinState['available'];
$data['supportPin'] = $pinState['pin'];
$data['frontendUrl'] = rtrim(Uri::root(), '/');
return $data;
}
@@ -1,40 +1,57 @@
<?php
/**
* MokoSuiteClient Cache & Temp Cleaner — status bar split button
* MokoSuiteClient Cache & Temp Cleaner — status bar with Clean dropdown
*
* Displays "Clear: Cache | Temp" as a single header item with two
* clickable halves. Uses native Atum header-item markup.
* Buttons: Frontend link | Support PIN | Clean ▾ (Cache / Temp)
*/
defined('_JEXEC') or die;
use Joomla\CMS\Session\Session;
$token = Session::getFormToken();
$cacheUrl = 'index.php?option=com_mokosuiteclient&task=display.clearCache&format=json';
$tempUrl = 'index.php?option=com_mokosuiteclient&task=display.clearTemp&format=json';
$domain = $domain ?? '';
$token = Session::getFormToken();
$cacheUrl = 'index.php?option=com_mokosuiteclient&task=display.clearCache&format=json';
$tempUrl = 'index.php?option=com_mokosuiteclient&task=display.clearTemp&format=json';
$pinAvailable = $supportPinAvailable ?? false;
$pin = $supportPin ?? '';
$frontendUrl = $frontendUrl ?? '';
?>
<?php if ($domain): ?>
<div class="header-item">
<span class="header-item-content" id="mokosuiteclient-domain" title="Support key — click to copy" style="cursor:pointer;">
<div class="header-item-icon"><span class="icon-key" aria-hidden="true"></span></div>
<div class="header-item-text"><?php echo htmlspecialchars($domain); ?></div>
</span>
</div>
<?php endif; ?>
<div class="header-item">
<a href="#" class="header-item-content" id="mokosuiteclient-clear-cache" title="Clear all Joomla cache">
<div class="header-item-icon"><span class="icon-bolt" aria-hidden="true" id="mokosuiteclient-cache-icon"></span></div>
<div class="header-item-text">Cache</div>
</a>
</div>
<div class="header-item">
<a href="#" class="header-item-content" id="mokosuiteclient-clear-temp" title="Clear temp directory">
<div class="header-item-icon"><span class="icon-trash" aria-hidden="true" id="mokosuiteclient-temp-icon"></span></div>
<div class="header-item-text">Temp</div>
</a>
<div class="header-item-content d-flex align-items-center gap-0" style="padding:0;">
<?php if ($frontendUrl): ?>
<a href="<?php echo htmlspecialchars($frontendUrl); ?>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-success rounded-0 rounded-start border-end-0 d-flex align-items-center gap-1 px-3 py-2" title="Open frontend" style="font-size:0.8rem;">
<span class="icon-external-link-alt" aria-hidden="true"></span> Site
</a>
<?php endif; ?>
<?php if ($pinAvailable):
$pinHtml = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderBadge(
['available' => true, 'pin' => $pin],
$token, 'cache'
);
if (!$frontendUrl) {
$pinHtml = str_replace('rounded-0 border-end-0', 'rounded-0 rounded-start border-end-0', $pinHtml);
}
echo $pinHtml;
endif; ?>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary <?php echo ($pinAvailable || $frontendUrl) ? 'rounded-0 rounded-end' : 'rounded'; ?> d-flex align-items-center gap-1 px-3 py-2 dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" style="font-size:0.8rem;" id="mokosuite-clean-dropdown">
<span class="icon-broom" aria-hidden="true"></span> Clean
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="mokosuite-clean-dropdown">
<li>
<a href="#" class="dropdown-item d-flex align-items-center gap-2" id="mokosuiteclient-clear-cache">
<span class="icon-bolt" aria-hidden="true" id="mokosuiteclient-cache-icon"></span> Clear Cache
</a>
</li>
<li>
<a href="#" class="dropdown-item d-flex align-items-center gap-2" id="mokosuiteclient-clear-temp">
<span class="icon-trash" aria-hidden="true" id="mokosuiteclient-temp-icon"></span> Clear Temp
</a>
</li>
</ul>
</div>
</div>
</div>
<script>
@@ -88,17 +105,6 @@ document.addEventListener('DOMContentLoaded', function() {
setupCleaner('mokosuiteclient-clear-cache', 'mokosuiteclient-cache-icon', '<?php echo $cacheUrl; ?>', '<?php echo $token; ?>');
setupCleaner('mokosuiteclient-clear-temp', 'mokosuiteclient-temp-icon', '<?php echo $tempUrl; ?>', '<?php echo $token; ?>');
// Click-to-copy domain
var domainEl = document.getElementById('mokosuiteclient-domain');
if (domainEl) {
domainEl.addEventListener('click', function() {
navigator.clipboard.writeText(domainEl.textContent.trim()).then(function() {
var orig = domainEl.textContent;
domainEl.textContent = 'Copied!';
setTimeout(function() { domainEl.textContent = orig; }, 1500);
});
});
}
});
</script>
<?php echo \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderScript(); ?>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>MOD_MOKOSUITECLIENT_CATEGORIES_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteClientCategories</namespace>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>MOD_MOKOSUITECLIENT_CPANEL_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteClientCpanel</namespace>
@@ -47,30 +47,10 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
$data['currentIp'] = $helper->getCurrentIp();
$data['ssl'] = $helper->getSslStatus();
// Daily support PIN derived from health token + today's date (UTC)
$data['supportPin'] = '';
try
{
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
);
$coreParams = json_decode((string) $db->loadResult());
$token = $coreParams->health_api_token ?? '';
if (!empty($token))
{
$date = gmdate('Y-m-d');
$hash = hash_hmac('sha256', $date, $token);
$data['supportPin'] = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
}
}
catch (\Throwable $e) {}
// Support PIN via shared helper
$pinState = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::getState($db);
$data['supportPinAvailable'] = $pinState['available'];
$data['supportPin'] = $pinState['pin'];
return $data;
}
@@ -32,9 +32,11 @@ class CpanelHelper
$pkgCache = json_decode($db->loadResult() ?? '{}');
return (object) [
'sitename' => $config->get('sitename', ''),
'mokosuiteclient_version' => $pkgCache->version ?? '',
'joomla_version' => (new Version())->getShortVersion(),
'php_version' => PHP_VERSION,
'db_type' => $config->get('dbtype', 'mysql'),
'debug' => (bool) $config->get('debug'),
'offline' => (bool) $config->get('offline'),
];
@@ -1,9 +1,6 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage mod_mokosuiteclient_cpanel
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* MokoSuiteClient Cpanel — slim bar with dropdown detail panel
*/
defined('_JEXEC') or die;
@@ -13,35 +10,21 @@ use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
// Hidden when on MokoSuiteClient dashboard (redundant info)
if (!empty($hidden)) return;
$siteInfo = $siteInfo ?? (object) [];
$plugins = $plugins ?? [];
$healthOk = $healthOk ?? true;
$counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0];
$disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null];
$currentIp = $currentIp ?? '';
$collapsed = true;
$showHealth = $params->get('show_health', 1);
$showStats = $params->get('show_stats', 1);
$showDisk = $params->get('show_disk', 1);
$showIp = $params->get('show_ip', 1);
$showPlugins = $params->get('show_plugins', 1);
$showActions = $params->get('show_actions', 1);
$showVersions = $params->get('show_versions', 1);
$token = Session::getFormToken();
$token = Session::getFormToken();
$showPlugins = $params->get('show_plugins', 1);
$enabledCount = 0;
$totalCount = count($plugins);
foreach ($plugins as $p)
{
if ($p->enabled)
{
$enabledCount++;
}
foreach ($plugins as $p) {
if ($p->enabled) $enabledCount++;
}
$labels = [
@@ -52,39 +35,106 @@ $labels = [
'mokosuiteclient_offline' => 'Offline Bypass',
'mokosuiteclient_dbip' => 'GeoIP Lookup',
'mokosuiteclient_license' => 'License Manager',
'mokosuiteclient_backup' => 'Backup Bridge',
];
$diskPct = ($disk->total_mb && $disk->total_mb > 0)
? round((($disk->total_mb - ($disk->free_mb ?? 0)) / $disk->total_mb) * 100)
: null;
$diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !== null && $diskPct > 75) ? 'bg-warning' : 'bg-success');
$diskPct = ($disk->total_mb && $disk->total_mb > 0)
? round((($disk->total_mb - ($disk->free_mb ?? 0)) / $disk->total_mb) * 100) : null;
$diskColor = ($diskPct !== null && $diskPct > 90) ? 'danger' : (($diskPct !== null && $diskPct > 75) ? 'warning' : 'success');
$canDashboard = Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokosuiteclient');
$siteName = htmlspecialchars($siteInfo->sitename ?? '', ENT_QUOTES, 'UTF-8');
$mokoVer = htmlspecialchars($siteInfo->mokosuiteclient_version ?? '', ENT_QUOTES, 'UTF-8');
$joomlaVer = htmlspecialchars($siteInfo->joomla_version ?? '', ENT_QUOTES, 'UTF-8');
$phpVer = htmlspecialchars($siteInfo->php_version ?? '', ENT_QUOTES, 'UTF-8');
$dbType = htmlspecialchars($siteInfo->db_type ?? '', ENT_QUOTES, 'UTF-8');
$ipEscaped = htmlspecialchars($currentIp, ENT_QUOTES, 'UTF-8');
$statusDots = [];
if (!empty($siteInfo->debug)) $statusDots[] = '<span class="badge bg-warning text-dark" style="font-size:0.7rem">Debug</span>';
if (!empty($siteInfo->offline)) $statusDots[] = '<span class="badge bg-danger" style="font-size:0.7rem">Offline</span>';
if (($counts->updates ?? 0) > 0) $statusDots[] = '<span class="badge bg-info" style="font-size:0.7rem">' . (int)$counts->updates . ' updates</span>';
?>
<div class="mod-mokosuiteclient-cpanel card p-3 mb-4">
<div class="d-flex flex-wrap align-items-center gap-2" style="font-size:0.85rem;">
<?php $canDashboard = Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokosuiteclient'); ?>
<div class="mod-mokosuiteclient-cpanel mb-2" style="font-size:0.82rem;">
<div class="d-flex align-items-center gap-2 px-3 py-1 rounded" style="background:linear-gradient(135deg,#f8f9fa 0%,#e9ecef 100%);border:1px solid #dee2e6;">
<?php if ($canDashboard): ?>
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient'); ?>" style="color:#1a2744;text-decoration:none;" title="MokoSuite Dashboard"><span class="icon-shield-alt" aria-hidden="true" style="font-size:1.1rem"></span></a>
<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:1rem"></span>
</a>
<?php else: ?>
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.1rem;color:#1a2744"></span>
<?php endif; ?>
<span class="fw-bold"><?php echo htmlspecialchars($siteInfo->sitename ?? ''); ?></span>
<span class="badge bg-primary">MokoSuite <?php echo htmlspecialchars($siteInfo->mokosuiteclient_version ?? ''); ?></span>
<?php if (!empty($supportPin)): ?>
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="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)): ?>
<span class="badge bg-warning text-dark">Debug ON</span>
<?php endif; ?>
<?php if (!empty($siteInfo->offline)): ?>
<span class="badge bg-danger">Offline</span>
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1rem;color:#1a2744"></span>
<?php endif; ?>
<span class="fw-semibold"><?php echo $siteName; ?></span>
<span class="text-muted" style="font-size:0.75rem">v<?php echo $mokoVer; ?></span>
<?php echo implode(' ', $statusDots); ?>
<?php echo \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderBadge(
['available' => !empty($supportPinAvailable), 'pin' => $supportPin ?? ''],
$token, 'cpanel'
); ?>
<span class="ms-auto d-flex align-items-center gap-2">
<span class="icon-globe" aria-hidden="true"></span>
<code><?php echo htmlspecialchars($currentIp); ?></code>
<span class="text-muted" style="font-size:0.75rem">
<span class="icon-globe" aria-hidden="true"></span> <code style="font-size:0.75rem"><?php echo $ipEscaped; ?></code>
</span>
<button class="btn btn-sm btn-link text-muted p-0 ms-1" type="button" data-bs-toggle="collapse" data-bs-target="#mokosuite-cpanel-detail" aria-expanded="false" aria-controls="mokosuite-cpanel-detail" title="Show details" style="font-size:0.85rem;text-decoration:none;">
<span class="icon-chevron-down mokosuite-cpanel-chevron" aria-hidden="true" style="transition:transform 0.2s"></span>
</button>
</span>
</div>
<div class="collapse" id="mokosuite-cpanel-detail">
<div class="card card-body mt-1 p-3" style="font-size:0.82rem;border-color:#dee2e6;">
<div class="row g-3">
<div class="col-md-4">
<h6 class="text-muted mb-2" style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em">Environment</h6>
<div class="d-flex flex-wrap gap-1">
<span class="badge bg-primary">MokoSuite <?php echo $mokoVer; ?></span>
<span class="badge bg-secondary">Joomla <?php echo $joomlaVer; ?></span>
<span class="badge bg-secondary">PHP <?php echo $phpVer; ?></span>
<span class="badge bg-secondary"><?php echo $dbType; ?></span>
</div>
</div>
<div class="col-md-4">
<h6 class="text-muted mb-2" style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em">Stats</h6>
<div class="d-flex flex-wrap gap-2" style="font-size:0.8rem">
<span><span class="icon-file-alt" aria-hidden="true"></span> <?php echo (int)($counts->articles ?? 0); ?> articles</span>
<span><span class="icon-users" aria-hidden="true"></span> <?php echo (int)($counts->users ?? 0); ?> users</span>
<span><span class="icon-puzzle-piece" aria-hidden="true"></span> <?php echo (int)($counts->extensions ?? 0); ?> extensions</span>
</div>
<?php if ($diskPct !== null): ?>
<div class="mt-1">
<div class="progress" style="height:4px">
<div class="progress-bar bg-<?php echo $diskColor; ?>" style="width:<?php echo $diskPct; ?>%"></div>
</div>
<small class="text-muted">Disk <?php echo $diskPct; ?>% (<?php echo number_format(($disk->free_mb ?? 0) / 1024, 1); ?> GB free)</small>
</div>
<?php endif; ?>
</div>
<?php if ($showPlugins && $totalCount > 0): ?>
<div class="col-md-4">
<h6 class="text-muted mb-2" style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em">Plugins (<?php echo $enabledCount; ?>/<?php echo $totalCount; ?>)</h6>
<div class="d-flex flex-wrap gap-1">
<?php foreach ($plugins as $p):
$el = str_replace('plg_system_', '', $p->element);
$label = $labels[$el] ?? ucfirst(str_replace('mokosuiteclient_', '', $el));
$color = $p->enabled ? 'success' : 'secondary';
?>
<span class="badge bg-<?php echo $color; ?>" style="font-size:0.7rem"><?php echo htmlspecialchars($label); ?></span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<style>
[aria-expanded="true"] .mokosuite-cpanel-chevron { transform: rotate(180deg); }
</style>
<?php echo \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderScript(); ?>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu.</description>
<namespace path="src">Moko\Module\MokoSuiteClientMenu</namespace>
@@ -2,9 +2,8 @@
/**
* MokoSuiteClient Admin Sidebar Menu
*
* Each installed Moko component gets its own top-level collapsible section.
* com_mokosuitehq is always pinned first. com_mokosuiteclient uses static views
* as children. All other components auto-discover their submenu items.
* Single "MokoSuite" top-level item with all Moko ecosystem components
* as collapsible children underneath.
*/
defined('_JEXEC') or die;
@@ -17,16 +16,91 @@ $app = Factory::getApplication();
$currentOption = $app->getInput()->get('option', '');
$currentView = $app->getInput()->get('view', '');
// ── Static views for com_mokosuiteclient ──────────────────────────────────
$mokosuiteclientStaticViews = [
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'],
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions'],
['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokosuiteclient&view=htaccess'],
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy'],
['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokosuiteclient&view=waflog'],
['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokosuiteclient&view=database'],
['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokosuiteclient&view=cleanup'],
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient'],
// ── Static views for com_mokosuiteclient (ACL-gated) ──────────────────────
$user = $app->getIdentity();
$allViews = [
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient', 'acl' => 'mokosuiteclient.dashboard'],
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions', 'acl' => 'mokosuiteclient.extensions'],
['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokosuiteclient&view=htaccess', 'acl' => 'mokosuiteclient.htaccess'],
['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokosuiteclient&view=waflog', 'acl' => 'mokosuiteclient.security.waflog'],
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy', 'acl' => 'core.admin'],
['icon' => 'fa-solid fa-code', 'title' => 'Snippets', 'link' => 'index.php?option=com_mokosuiteclient&view=snippets', 'acl' => 'mokosuiteclient.snippets.manage'],
['icon' => 'fa-solid fa-file-lines', 'title' => 'Templates', 'link' => 'index.php?option=com_mokosuiteclient&view=templates', 'acl' => 'mokosuiteclient.templates.manage'],
['icon' => 'fa-solid fa-right-left', 'title' => 'Replacements', 'link' => 'index.php?option=com_mokosuiteclient&view=replacements','acl' => 'mokosuiteclient.replacements.manage'],
['icon' => 'fa-solid fa-shuffle', 'title' => 'Conditions', 'link' => 'index.php?option=com_mokosuiteclient&view=conditions', 'acl' => 'mokosuiteclient.conditions.manage'],
['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokosuiteclient&view=database', 'acl' => 'core.admin'],
['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokosuiteclient&view=cleanup', 'acl' => 'mokosuiteclient.cache'],
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient', 'acl' => 'core.admin'],
];
$isSuper = $user->authorise('core.admin', 'com_mokosuiteclient');
$mokosuiteclientStaticViews = array_filter($allViews, function ($v) use ($user, $isSuper) {
return $isSuper || $user->authorise($v['acl'], 'com_mokosuiteclient');
});
// ── Icon overrides (canonical icons per component) ───────────────────
$iconOverrides = [
'com_mokosuiteclient' => 'icon-shield-alt',
'com_mokosuitehq' => 'icon-tachometer-alt',
'com_mokosuitebackup' => 'icon-archive',
'com_mokosuitecrm' => 'icon-address-book',
'com_mokosuiteerp' => 'icon-briefcase',
'com_mokosuiteshop' => 'icon-shopping-cart',
'com_mokosuitepos' => 'icon-calculator',
'com_mokosuitemrp' => 'icon-cogs',
'com_mokosuitehrm' => 'icon-id-badge',
'com_mokosuiterestaurant' => 'icon-utensils',
'com_mokosuitechild' => 'icon-child',
'com_mokosuitenpo' => 'icon-heart',
'com_mokosuitefield' => 'icon-wrench',
'com_mokosuitecreate' => 'icon-paint-brush',
'com_mokosuiteforms' => 'icon-list-alt',
'com_mokosuitecommunity' => 'icon-comments',
'com_mokosuitecross' => 'icon-share-alt',
'com_mokosuiteopengraph' => 'icon-globe',
'com_mokosuitestorelocator' => 'icon-map-marker-alt',
];
$childIconMap = [
'dashboard' => 'icon-tachometer-alt',
'contacts' => 'icon-address-book',
'deals' => 'icon-handshake',
'activities' => 'icon-clock',
'tickets' => 'icon-life-ring',
'helpdesk' => 'icon-life-ring',
'products' => 'icon-box',
'orders' => 'icon-shopping-cart',
'invoices' => 'icon-file-invoice',
'inventory' => 'icon-warehouse',
'settings' => 'icon-cog',
'config' => 'icon-cog',
'options' => 'icon-sliders-h',
'reports' => 'icon-chart-bar',
'analytics' => 'icon-chart-line',
'locations' => 'icon-map-marker-alt',
'stores' => 'icon-store',
'categories' => 'icon-folder',
'forms' => 'icon-list-alt',
'submissions' => 'icon-inbox',
'emails' => 'icon-envelope',
'campaigns' => 'icon-bullhorn',
'users' => 'icon-users',
'members' => 'icon-id-card',
'employees' => 'icon-id-badge',
'donors' => 'icon-hand-holding-heart',
'donations' => 'icon-donate',
'events' => 'icon-calendar',
'tasks' => 'icon-tasks',
'projects' => 'icon-project-diagram',
'templates' => 'icon-file-alt',
'announcements'=> 'icon-bullhorn',
'plugins' => 'icon-plug',
'import' => 'icon-upload',
'export' => 'icon-download',
'log' => 'icon-list',
'backup' => 'icon-archive',
'channels' => 'icon-share-alt',
'posts' => 'icon-paper-plane',
'schedule' => 'icon-calendar-alt',
];
// ── Auto-discover all Moko components from #__menu ──────────────────
@@ -47,7 +121,6 @@ try
);
$menuItems = $db->loadObjectList() ?: [];
// Load language files for discovered components
$lang = Factory::getLanguage();
$loadedLangs = [];
foreach ($menuItems as $m)
@@ -56,38 +129,50 @@ try
{
$lang->load($m->element . '.sys', JPATH_ADMINISTRATOR);
$lang->load($m->element, JPATH_ADMINISTRATOR);
$compLangPath = JPATH_ADMINISTRATOR . '/components/' . $m->element;
if (is_dir($compLangPath . '/language'))
{
$lang->load($m->element . '.sys', $compLangPath);
$lang->load($m->element, $compLangPath);
}
$loadedLangs[$m->element] = true;
}
}
// Group: level 1 = component parent, level 2 = children
foreach ($menuItems as $m)
{
if ((int) $m->level === 1)
{
$icon = $iconOverrides[$m->element]
?? str_replace('class:', 'icon-', $m->img ?: 'class:puzzle-piece');
$mokoComponents[$m->element] = [
'id' => $m->id,
'title' => Text::_($m->title),
'link' => $m->link,
'icon' => str_replace('class:', 'icon-', $m->img ?: 'class:puzzle-piece'),
'icon' => $icon,
'element' => $m->element,
'children' => [],
];
}
elseif ((int) $m->level === 2 && isset($mokoComponents[$m->element]))
{
$childIcon = str_replace('class:', 'icon-', $m->img ?: '');
if ($childIcon === '' || $childIcon === 'icon-cog' || $childIcon === 'icon-cogs')
{
$parsed = [];
parse_str(parse_url($m->link, PHP_URL_QUERY) ?? '', $parsed);
$viewKey = strtolower($parsed['view'] ?? '');
$childIcon = $childIconMap[$viewKey] ?? '';
}
$mokoComponents[$m->element]['children'][] = [
'title' => Text::_($m->title),
'link' => $m->link,
'icon' => str_replace('class:', 'icon-', $m->img ?: 'class:cog'),
'icon' => $childIcon ?: 'icon-angle-right',
];
}
}
}
catch (\Throwable $e)
{
// Silent — menu works without auto-discovered components
}
catch (\Throwable $e) {}
// Override com_mokosuiteclient children with static views
if (isset($mokoComponents['com_mokosuiteclient']))
@@ -97,7 +182,6 @@ if (isset($mokoComponents['com_mokosuiteclient']))
}
else
{
// com_mokosuiteclient not in admin menu — add it manually
$mokoComponents['com_mokosuiteclient'] = [
'id' => 0,
'title' => 'MokoSuite',
@@ -115,9 +199,6 @@ $rest = [];
foreach ($mokoComponents as $key => $comp)
{
// Shorten display titles:
// MokoSuiteClient → MokoSuite, MokoSuiteHQ → MokoHQ
// Everything else: MokoSuiteBackup → Backup, MokoSuiteOpenGraph → OpenGraph
if ($key === 'com_mokosuiteclient')
{
$comp['title'] = 'MokoSuite';
@@ -131,35 +212,29 @@ foreach ($mokoComponents as $key => $comp)
$comp['title'] = preg_replace('/^MokoSuite\s*/i', '', $comp['title']);
}
if ($key === 'com_mokosuitehq')
{
$hq = $comp;
}
elseif ($key === 'com_mokosuiteclient')
{
$client = $comp;
}
else
{
$rest[$key] = $comp;
}
if ($key === 'com_mokosuitehq') { $hq = $comp; }
elseif ($key === 'com_mokosuiteclient') { $client = $comp; }
else { $rest[$key] = $comp; }
}
usort($rest, fn($a, $b) => strcasecmp($a['title'], $b['title']));
$sorted = [];
if ($hq !== null)
if ($hq !== null) $sorted[] = $hq;
if ($client !== null) $sorted[] = $client;
foreach ($rest as $comp) $sorted[] = $comp;
// Is ANY Moko component active?
$anyActive = false;
foreach ($sorted as $comp)
{
$sorted[] = $hq;
}
if ($client !== null)
{
$sorted[] = $client;
}
foreach ($rest as $comp)
{
$sorted[] = $comp;
$p = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $p);
if (($p['option'] ?? '') === $currentOption) { $anyActive = true; break; }
}
if ($currentOption === 'com_plugins') $anyActive = true;
$iconStyle = 'display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;';
?>
<style>
@@ -167,58 +242,64 @@ foreach ($rest as $comp)
</style>
<ul class="nav flex-column main-nav">
<?php foreach ($sorted as $comp): ?>
<?php
$compParsed = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
$compOption = $compParsed['option'] ?? '';
$compActive = ($compOption === $currentOption);
// For com_mokosuiteclient static children, also check the plugins filter link
if (!$compActive && $comp['element'] === 'com_mokosuiteclient' && $currentOption === 'com_plugins')
{
$compActive = true;
}
$hasChildren = !empty($comp['children']);
$liClass = 'item mokosuiteclient-ext-item' . ($hasChildren ? ' parent item-level-1' : '') . ($compActive ? ' mm-active' : '');
$aClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
$childCollapse = 'collapse-level-1 mm-collapse' . ($compActive ? ' mm-show' : '');
?>
<li class="<?php echo $liClass; ?>">
<a class="<?php echo $aClass; ?>" href="<?php echo $hasChildren ? '#' : Route::_($comp['link']); ?>"<?php echo ($compActive && !$hasChildren) ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $comp['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $comp['title']; ?></span>
<li class="item parent item-level-1 mokosuiteclient-ext-item<?php echo $anyActive ? ' mm-active' : ''; ?>">
<a class="has-arrow<?php echo $anyActive ? ' mm-active' : ''; ?>" href="#">
<span class="icon-shield-alt" aria-hidden="true" style="<?php echo $iconStyle; ?>"></span>
<span class="sidebar-item-title">MokoSuite</span>
</a>
<?php if ($hasChildren): ?>
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.5rem;">
<?php foreach ($comp['children'] as $child): ?>
<ul class="collapse-level-1 mm-collapse<?php echo $anyActive ? ' mm-show' : ''; ?>" style="padding-inline-start:0.5rem;">
<?php foreach ($sorted as $comp): ?>
<?php
$childParsed = [];
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
$childOption = $childParsed['option'] ?? '';
$childView = $childParsed['view'] ?? '';
$childActive = false;
if ($childOption === $currentOption)
$compParsed = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
$compOption = $compParsed['option'] ?? '';
$compActive = ($compOption === $currentOption);
if (!$compActive && $comp['element'] === 'com_mokosuiteclient' && $currentOption === 'com_plugins')
{
$childActive = empty($childView)
? ($currentView === '' || $currentView === 'dashboard')
: ($currentView === $childView);
$compActive = true;
}
$childLiClass = 'item mokosuiteclient-ext-child' . ($childActive ? ' mm-active' : '');
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
$hasChildren = !empty($comp['children']);
?>
<li class="<?php echo $childLiClass; ?>">
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $child['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $child['title']; ?></span>
<?php if ($hasChildren): ?>
<li class="item parent item-level-2 mokosuiteclient-ext-item<?php echo $compActive ? ' mm-active' : ''; ?>">
<a class="has-arrow<?php echo $compActive ? ' mm-active' : ''; ?>" href="#">
<span class="<?php echo htmlspecialchars($comp['icon'], ENT_QUOTES, 'UTF-8'); ?>" aria-hidden="true" style="<?php echo $iconStyle; ?>"></span>
<span class="sidebar-item-title"><?php echo htmlspecialchars($comp['title'], ENT_QUOTES, 'UTF-8'); ?></span>
</a>
<ul class="collapse-level-2 mm-collapse<?php echo $compActive ? ' mm-show' : ''; ?>" style="padding-inline-start:0.5rem;">
<?php foreach ($comp['children'] as $child): ?>
<?php
$childParsed = [];
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
$childOption = $childParsed['option'] ?? '';
$childView = $childParsed['view'] ?? '';
$childActive = false;
if ($childOption === $currentOption)
{
$childActive = empty($childView)
? ($currentView === '' || $currentView === 'dashboard')
: ($currentView === $childView);
}
?>
<li class="item mokosuiteclient-ext-child<?php echo $childActive ? ' mm-active' : ''; ?>">
<a class="no-dropdown<?php echo $childActive ? ' mm-active' : ''; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo htmlspecialchars($child['icon'], ENT_QUOTES, 'UTF-8'); ?>" aria-hidden="true" style="<?php echo $iconStyle; ?>"></span>
<span class="sidebar-item-title"><?php echo htmlspecialchars($child['title'], ENT_QUOTES, 'UTF-8'); ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
</li>
<?php else: ?>
<li class="item mokosuiteclient-ext-item<?php echo $compActive ? ' mm-active' : ''; ?>">
<a class="no-dropdown<?php echo $compActive ? ' mm-active' : ''; ?>" href="<?php echo Route::_($comp['link']); ?>"<?php echo $compActive ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo htmlspecialchars($comp['icon'], ENT_QUOTES, 'UTF-8'); ?>" aria-hidden="true" style="<?php echo $iconStyle; ?>"></span>
<span class="sidebar-item-title"><?php echo htmlspecialchars($comp['title'], ENT_QUOTES, 'UTF-8'); ?></span>
</a>
</li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
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.48.52
* PATH: /src/Field/ArticlesField.php
* BRIEF: List field that populates with published Joomla articles
*/
namespace Moko\Plugin\System\MokoSuiteClient\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\Field\ListField;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Form field that renders a dropdown (or multi-select) of all published
* articles, grouped by category name.
*
* Usage in XML:
* <field name="related_article" type="Articles" label="Related Article" multiple="true" />
*
* @since 02.47.62
*/
class ArticlesField extends ListField
{
protected $type = 'Articles';
protected function getOptions(): array
{
$options = parent::getOptions();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select([
$db->quoteName('a.id', 'value'),
$db->quoteName('a.title', 'text'),
$db->quoteName('c.title', 'category'),
])
->from($db->quoteName('#__content', 'a'))
->leftJoin($db->quoteName('#__categories', 'c') . ' ON c.id = a.catid')
->where($db->quoteName('a.state') . ' = 1')
->order($db->quoteName('a.title') . ' ASC');
$db->setQuery($query);
$articles = $db->loadObjectList() ?: [];
foreach ($articles as $article) {
$label = $article->text;
if (!empty($article->category)) {
$label .= ' [' . $article->category . ']';
}
$options[] = HTMLHelper::_('select.option', $article->value, $label);
}
return $options;
}
}
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuiteClient
* VERSION: 02.47.48
* VERSION: 02.48.52
* PATH: /src/Field/CopyableTokenField.php
* BRIEF: Read-only token field with a copy-to-clipboard button
*/
@@ -30,7 +30,7 @@
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
<namespace path=".">Moko\Plugin\System\MokoSuiteClient</namespace>
<scriptfile>script.php</scriptfile>
@@ -99,7 +99,94 @@
description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC"
filter="url" />
<field name="monitor_signing_key" type="hidden"
<field name="auto_clear_cache" type="radio" default="0"
label="Auto-Clear Cache on Save"
description="Automatically clear Joomla cache when articles, modules, or extensions are saved."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="protect_emails" type="radio" default="0"
label="Email Protection"
description="Obfuscate email addresses in HTML output to prevent spam bot harvesting. Uses JavaScript decloaking."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="snippets_enabled" type="radio" default="0"
label="Snippets"
description="Enable {snippet alias=&quot;name&quot;} 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=&quot;name&quot;} 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=&quot;42&quot;}[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=&quot;42&quot;}[name]{/user} for specific users or {user name} for the current logged-in user."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="users_allow_email" type="radio" default="0"
label="Users: Show Email"
description="Allow the [email] placeholder in {user} tags to display the real email address. When disabled, emails are masked."
class="btn-group btn-group-yesno"
showon="users_anywhere_enabled:1">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="users_allow_username" type="radio" default="1"
label="Users: Show Username"
description="Allow the [username] placeholder in {user} tags to display the real username. When disabled, usernames are masked."
class="btn-group btn-group-yesno"
showon="users_anywhere_enabled:1">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="replacements_enabled" type="radio" default="0"
label="ReReplacer"
description="Enable backend-managed string and regex replacement rules. Published rules from the replacements table are applied to site and/or admin content."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="sourcerer_enabled" type="radio" default="0"
label="Code Embedding (Sourcerer)"
description="Allow embedding PHP, JavaScript, and CSS code in content via {source} tags. Security restricted."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="sourcerer_forbidden_functions" type="text" default="exec,system,passthru,shell_exec,popen,proc_open,dl,eval,call_user_func,call_user_func_array,assert,array_map,array_filter,array_walk,usort,uasort,uksort,create_function,preg_replace_callback,ob_start"
label="Forbidden PHP Functions"
description="Comma-separated list of PHP functions blocked in {source} tags. Backtick operator is always blocked."
showon="sourcerer_enabled:1" />
<field name="monitor_signing_key" type="hidden"
default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQ2xZNnNzOTZpeTZOOGMKTHRxbndhbnU4eEozdDcrdDhXT3hoY0Yyclc2QmlmOVhNaEpnYkw0c055N0wwV1dTT2tkMmZxalBNcDFtOFNyNAo1VnNycjE3cFc5b0FNMmtmdFdsaTZ1NkhTVEYyN2pVVUJrT3o4MHZMRklMMGNGNkJCUkpYN2JVWkRpamdUMjc1ClREb3dXZy82Zk9GeWFEelBHUkJuYXFacTljU2lEYWoyNlpSTVZIbktQUERWTG92VzRPTDQzL2gwZ3BtN25nUGIKdWJlLzFFTDRUMHFRbm1Xc2FEOFZ6VStoRXFGSDRTVUtMaDVNeklGbUxFZzRlZ0xCbTBXcWdxbzZRQVBnZDVPYgoybXhmQndta3RLVm5hcWR6eG9KSytzaTVuZkYreGpxbWRMZThUdmEyTHNuTUxlZmsrODVoQ3hxS2x1eWRta1lXCjlvUk5qcDhiQWdNQkFBRUNnZ0VBQkZOUS9NSVZaV2gxdlZUMFh3TFBvUEkyZjI4TTBrM0gzN0t4MXBxK2t5QzYKenRyK1pBczBCaEFEWjAwNHJOUmRYaG45N0QxVXBJYVdLeUJFZkNZQUEzWmxneS9WQmdGR21sR3VuMWNvdGdXUQoyYzg0SWhLdzNzVFFqL2dJWUxOelFWMTBLUTJYd0JZVHZ1MWhjRFpLeUxCUGJTQ1F4cEhQUGdVcUNRNFljR3lFClErVmc1dHJUYk8wQ2xCZ1U5bkVnYU1RakRJZ0F3WVZPV203dUxJTW84UC9nT3FuT2tmaFhzdzl3VTJVYWxFeTEKRmRZbGhMbGJ0ZS9MZ3lkYlJ2RStjNEtqZVp0Z3ptc1RneEh2dzM5YVVmZUZTclFRT0FjcXc0alNzUjdMck9UZAp5bDhpelRrZVBrTVFMamFqR0pabWdPbitkRzhtUlpMa3FKcWdGaVpqRVFLQmdRRFV0L0xlU0h5SmhvY3VFL240CkZreEpaclJoWUVsWnc2WlZJUnQzWDlPQ1Nmaklab3I1ZkZlczhvUzZySFhKdGZYeWx4QUxOSjJjTUhKTTViVnUKbUFSUFU4cThBeVc0OE03cHAyNmtVVTMxNXc2OU1SUkhzbWgyekRabEtDeG5GM1NSQ3U4YW95d3hZc3RUZ3hkTgo2bDhLNHZsS1dsN3FYblBhWjZjb3lQSU9od0tCZ1FESENuRmRRdW5SMVI2dkxGaVFZMTRiT3QwT0tzVGJYMUJyCmpvUGZySkxvRm5mSCs4VDVnNUdxYkV5T2p0WG1tRXhmTFFpcDBQVXRtc1E0YXlJRFBZYWZtU3RpK2dtQXZFd1MKZTlKcVYxYlRuazUrYnVRZ2FlOW16REpJWkxaczRJUlhrd1Q5aDZ4Q2xKeS80TGJSRHdBU3dUVGJlY01hN3A4UgpQN0p0bjdsYnpRS0JnQzNOR2FjUTFuZktGb3N1VS9FOTQ5a2VHeEtvWjhMREpLcEp3WjgzYTlRdTF6bFhFdTlhCi9ZbklnaG1yam9VSy85VG0vOVpaMHVIUmNKcnNEdCtzTGFsaThsRC9JSDBzcEhDYzAyN2Y3cmhXc3M2N3BaRTIKY2RXNmJLL2xNWUpWQTQxRFhHNVEyZkFjUklsTHZaWFNNL3FsR21ZUEJVYlRaWUNPTnVqS000dzdBb0dBU1dBdwpLcEZnWVZxUDFVUWo0aGEvdW9vWXRBQlFVZzd4TnJWektDSVdoampDTDVkQkpqcTZtSGtVUC9tb0lUcEQ3VkpNCnYwMnBGUWJaRDNOdk5vS1gvbjRZNElRTXZNaXR3cUtqRDFEalVXQXF6N0ZScUNGbGdDQUc2V2szVnl2dG5kczEKRzhISVgwTXFCaEp4VXVDVXhsVXpoelY4RjVHZ1VsdUpDNkMyVklFQ2dZQkJWSkxpZlNVOTlHWGZtK3dPd0RWcgo2bHZoUFgxOTBGVktWQXY3aVVWTXBwWXg4Y0QxYkcyUjRLT29JbnkxYTlxdjA2ZGFzeGVQOStkVjJVMWU3MWl5CkFXWDRBVHIrYitvSGk2eUk1MXRHRk54RUxiNXZYMVpYM3VNaDlWM29iYUpuSFNjYllpKzBBNjlyRmNuNEZuLzUKWXJybWxLTzRlRHFVZkswbVFJVCtwUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"
filter="raw" />
</fieldset>
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuiteClient
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
* VERSION: 02.47.48
* VERSION: 02.48.52
* PATH: /src/script.php
* BRIEF: Installation script for MokoSuiteClient plugin
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuiteClient
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
* VERSION: 02.47.48
* VERSION: 02.48.52
* PATH: /src/services/provider.php
* BRIEF: Service provider for dependency injection in Joomla 5.x
* NOTE: Registers the plugin with Joomla's DI container
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientBackup</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDBIP</namespace>
@@ -23,6 +23,7 @@ class DBIP extends CMSPlugin implements SubscriberInterface
{
return [
'onAfterInitialise' => 'onAfterInitialise',
'onAfterRender' => 'onAfterRender',
];
}
@@ -80,4 +81,92 @@ class DBIP extends CMSPlugin implements SubscriberInterface
DBIPHelper::downloadCityDb($url);
}
/**
* Scan rendered admin HTML for IP addresses and enrich with geo flags + tooltips.
*/
public function onAfterRender(): void
{
$app = $this->getApplication();
if (!$app->isClient('administrator'))
{
return;
}
if ($app->getDocument()->getType() !== 'html')
{
return;
}
$body = $app->getBody();
if (empty($body))
{
return;
}
$ipv4 = '(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)';
$ipv6 = '(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}(?::(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))?';
$pattern = '#<code(?:\s[^>]*)?>(' . $ipv4 . '|' . $ipv6 . ')</code>#';
$cache = [];
$newBody = preg_replace_callback($pattern, function ($m) use (&$cache) {
$fullMatch = $m[0];
$ip = $m[1];
// Skip private/loopback
if (filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE) === false)
{
return $fullMatch;
}
// Already enriched (has a title attribute)
if (strpos($fullMatch, 'title=') !== false)
{
return $fullMatch;
}
if (!isset($cache[$ip]))
{
$cache[$ip] = DBIPHelper::lookup($ip);
}
$geo = $cache[$ip];
if ($geo === null || empty($geo['country_code']))
{
return $fullMatch;
}
$cc = strtoupper($geo['country_code']);
$flag = self::countryFlag($cc);
$parts = array_filter([$geo['city'], $geo['region'], $geo['country_name']]);
$tooltip = htmlspecialchars(implode(', ', $parts), \ENT_QUOTES, 'UTF-8');
$escaped = htmlspecialchars($ip, \ENT_QUOTES, 'UTF-8');
return $flag . ' <code title="' . $tooltip . '" style="cursor:help;">' . $escaped . '</code>';
}, $body);
if ($newBody !== null && $newBody !== $body)
{
$app->setBody($newBody);
}
}
private static function countryFlag(string $cc): string
{
if (\strlen($cc) !== 2)
{
return '';
}
$cc = strtoupper($cc);
$first = mb_chr(0x1F1E6 + \ord($cc[0]) - \ord('A'));
$second = mb_chr(0x1F1E6 + \ord($cc[1]) - \ord('A'));
return $first . $second;
}
}
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDevTools</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientFirewall</namespace>
@@ -0,0 +1,12 @@
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE="System - MokoSuiteClient License"
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC="Validates download/update keys against the MokoSuite license server."
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_FIELDSET_BASIC="License Settings"
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_FIELDSET_BASIC_DESC="Configure the license server connection and caching behaviour."
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_SERVER_URL_LABEL="License Server URL"
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_SERVER_URL_DESC="Base URL of the MokoSuite license server (Gitea instance)."
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_ORG_KEY_LABEL="Organisation API Key"
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_ORG_KEY_DESC="API token used for licence validation requests."
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_CACHE_TTL_LABEL="Cache TTL (hours)"
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_CACHE_TTL_DESC="How long a successful licence check is cached before re-validation."
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_GRACE_LABEL="Grace Period (hours)"
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_GRACE_DESC="How long the site continues to function after a failed licence check."
@@ -0,0 +1,2 @@
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE="System - MokoSuiteClient License"
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC="Validates download/update keys against the MokoSuite license server."
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientLicense</namespace>
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
@@ -0,0 +1,32 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage plg_system_mokosuiteclient_license
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoSuiteClientLicense\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
/**
* MokoSuiteClient License Plugin
*
* Validates download/update keys against the configured license server
* and caches the result for the configured TTL.
*
* @since 02.34.84
*/
class License extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [];
}
}
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientOffline</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientTenant</namespace>
@@ -12,7 +12,7 @@
<license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>PLG_TASK_MOKOSUITECLIENTDEMO_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
@@ -10,7 +10,7 @@
* INGROUP: MokoSuiteClient
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
* PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php
* VERSION: 02.47.48
* VERSION: 02.48.52
* BRIEF: Content-only snapshot/restore for demo site reset
*/
@@ -12,7 +12,7 @@
<license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>PLG_TASK_MOKOSUITECLIENTSYNC_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace>
@@ -10,7 +10,7 @@
* INGROUP: MokoSuiteClient
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php
* VERSION: 02.47.48
* VERSION: 02.48.52
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
*/
@@ -10,7 +10,7 @@
* INGROUP: MokoSuiteClient
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php
* VERSION: 02.47.48
* VERSION: 02.48.52
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
*/
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.47.48</version>
<version>02.48.52</version>
<description>Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info.</description>
<namespace path="src">Moko\Plugin\WebServices\MokoSuiteClient</namespace>
<files>
@@ -155,22 +155,5 @@ final class MokoSuiteClientApi extends CMSPlugin implements SubscriberInterface
)
);
// Helpdesk Tickets API (#142)
$router->createCRUDRoutes(
'v1/mokosuiteclient/tickets',
'tickets',
['component' => 'com_mokosuiteclient']
);
// Ticket reply (custom route — POST only)
$router->addRoute(
new \Joomla\Router\Route(
['POST'],
'v1/mokosuiteclient/tickets/:id/reply',
'tickets.reply',
['id' => '(\d+)'],
['component' => 'com_mokosuiteclient']
)
);
}
}
+5 -2
View File
@@ -2,14 +2,14 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteClient</name>
<packagename>mokosuiteclient</packagename>
<version>02.47.48</version>
<version>02.48.52</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>MokoSuiteClient site management suite admin dashboard, security firewall, tenant restrictions, health monitoring, developer tools, and REST API.</description>
<description>MokoSuiteClient site management suite: admin dashboard, security firewall, tenant restrictions, health monitoring, developer tools, and REST API.</description>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall>
<scriptfile>script.php</scriptfile>
@@ -20,6 +20,9 @@
<file type="plugin" id="plg_system_mokosuiteclient_tenant" group="system">plg_system_mokosuiteclient_tenant.zip</file>
<file type="plugin" id="plg_system_mokosuiteclient_devtools" group="system">plg_system_mokosuiteclient_devtools.zip</file>
<file type="plugin" id="plg_system_mokosuiteclient_offline" group="system">plg_system_mokosuiteclient_offline.zip</file>
<file type="plugin" id="plg_system_mokosuiteclient_backup" group="system">plg_system_mokosuiteclient_backup.zip</file>
<file type="plugin" id="plg_system_mokosuiteclient_dbip" group="system">plg_system_mokosuiteclient_dbip.zip</file>
<file type="plugin" id="plg_system_mokosuiteclient_license" group="system">plg_system_mokosuiteclient_license.zip</file>
<file type="component" id="com_mokosuiteclient">com_mokosuiteclient.zip</file>
<file type="module" id="mod_mokosuiteclient_cpanel" client="administrator">mod_mokosuiteclient_cpanel.zip</file>
<file type="module" id="mod_mokosuiteclient_menu" client="administrator">mod_mokosuiteclient_menu.zip</file>
+22
View File
@@ -79,6 +79,8 @@ class Pkg_MokosuiteclientInstallerScript
$this->enablePlugin('system', 'mokosuiteclient_devtools');
$this->enablePlugin('system', 'mokosuiteclient_offline');
$this->enablePlugin('system', 'mokosuiteclient_dbip');
$this->enablePlugin('system', 'mokosuiteclient_backup');
$this->enablePlugin('system', 'mokosuiteclient_license');
$this->enablePlugin('webservices', 'mokosuiteclient');
$this->enablePlugin('task', 'mokosuiteclientdemo');
$this->enablePlugin('task', 'mokosuiteclientsync');
@@ -120,6 +122,9 @@ class Pkg_MokosuiteclientInstallerScript
// Restore download key saved in preflight
$this->restoreDownloadKey();
// Flush PSR-4 autoload cache so Joomla 6+ picks up new/updated namespaces
$this->flushAutoloadCache();
// Trigger heartbeat registration
$this->sendHeartbeat();
@@ -1075,6 +1080,23 @@ class Pkg_MokosuiteclientInstallerScript
}
}
/**
* Delete the cached PSR-4 autoload map so Joomla rebuilds it from manifests.
*
* Joomla 6 replaced the #__extensions.namespace column with a file-based
* cache. Package installs sometimes regenerate it before all sub-extensions
* are extracted, leaving stale entries that cause class-not-found errors.
*/
private function flushAutoloadCache(): void
{
$cachePath = JPATH_ADMINISTRATOR . '/cache/autoload_psr4.php';
if (is_file($cachePath))
{
@unlink($cachePath);
}
}
/**
* Send heartbeat to the MokoSuiteClient monitoring receiver.
*