diff --git a/.mokogitea/CLAUDE.md b/.mokogitea/CLAUDE.md index ef8175ef..5a7679e0 100644 --- a/.mokogitea/CLAUDE.md +++ b/.mokogitea/CLAUDE.md @@ -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 diff --git a/.mokogitea/workflows/ci-platform.yml b/.mokogitea/workflows/ci-platform.yml deleted file mode 100644 index b17e62bf..00000000 --- a/.mokogitea/workflows/ci-platform.yml +++ /dev/null @@ -1,439 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0255a01c..c0de4af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 551d08bc..0090821b 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -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 --> diff --git a/GOVERNANCE.md b/GOVERNANCE.md index daf9a055..4330c7d9 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -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 --> diff --git a/LICENSE.md b/LICENSE.md index f7f03e47..03208e4b 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -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 diff --git a/README.md b/README.md index 260363a6..60f02d07 100644 --- a/README.md +++ b/README.md @@ -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 --> diff --git a/SECURITY.md b/SECURITY.md index 5efee604..f1c71579 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 --> diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md index a141e915..b37cf8cc 100644 --- a/docs/guides/build-guide.md +++ b/docs/guides/build-guide.md @@ -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 diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md index 492fd1be..19bc71de 100644 --- a/docs/guides/configuration-guide.md +++ b/docs/guides/configuration-guide.md @@ -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 diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md index fb2d2b9d..0f656a7a 100644 --- a/docs/guides/installation-guide.md +++ b/docs/guides/installation-guide.md @@ -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 diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md index 9fb283f3..e64b5a99 100644 --- a/docs/guides/operations-guide.md +++ b/docs/guides/operations-guide.md @@ -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 diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md index d4589ffa..6539fc04 100644 --- a/docs/guides/rollback-and-recovery-guide.md +++ b/docs/guides/rollback-and-recovery-guide.md @@ -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 diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index 254cc67b..6dacaa78 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -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 diff --git a/docs/guides/troubleshooting-guide.md b/docs/guides/troubleshooting-guide.md index 622233a3..aafcc5d3 100644 --- a/docs/guides/troubleshooting-guide.md +++ b/docs/guides/troubleshooting-guide.md @@ -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 diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md index 954d33a8..a92ffc07 100644 --- a/docs/guides/upgrade-and-versioning-guide.md +++ b/docs/guides/upgrade-and-versioning-guide.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 80439ffd..635fa0de 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md index 966fb7b9..168b1910 100644 --- a/docs/plugin-basic.md +++ b/docs/plugin-basic.md @@ -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 diff --git a/docs/update-server.md b/docs/update-server.md index de1fb183..bc53a2cd 100644 --- a/docs/update-server.md +++ b/docs/update-server.md @@ -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 --> diff --git a/source/packages/com_mokosuiteclient/admin/access.xml b/source/packages/com_mokosuiteclient/admin/access.xml index 41d8a779..ca153162 100644 --- a/source/packages/com_mokosuiteclient/admin/access.xml +++ b/source/packages/com_mokosuiteclient/admin/access.xml @@ -1,15 +1,29 @@
+ + + - - - - - + + + + + + + + + + + + + + + +
diff --git a/source/packages/com_mokosuiteclient/admin/catalog.xml b/source/packages/com_mokosuiteclient/admin/catalog.xml index d18e918a..266125d1 100644 --- a/source/packages/com_mokosuiteclient/admin/catalog.xml +++ b/source/packages/com_mokosuiteclient/admin/catalog.xml @@ -1,122 +1,210 @@ + MokoSuiteClient pkg_mokosuiteclient package - Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API. + Admin dashboard, security firewall, tenant restrictions, health monitoring, content tools, and REST API. icon-shield-alt Platform -
https://mokoconsulting.tech/support/products/mokosuiteclient-platform
true - https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/dev/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/main/updates.xml
- MokoSuiteClientHQ - pkg_mokosuiteclienthq - package - Centralized control panel for managing all MokoSuiteClient client installations. - icon-tachometer-alt - Platform -
https://mokoconsulting.tech/support/products/mokosuiteclient-base
- https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientHQ/raw/branch/dev/updates.xml -
- - MokoOnyx - mokoonyx - template - Modern Joomla site template with dark mode, custom layouts, and MokoSuiteClient integration. - icon-paint-brush - Templates -
https://mokoconsulting.tech/support/products/mokoonyx-template
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/dev/updates.xml -
- - MokoJoomOpenGraph - pkg_mokoog - package - Open Graph, Twitter Card, and social sharing meta tags for articles, categories, and pages. - icon-share-alt - SEO -
https://mokoconsulting.tech/support/products/mokojoomopengraph
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml -
- - MokoSuiteClientBackup - pkg_mokojoombackup + MokoSuiteBackup + pkg_mokosuitebackup package Full-site backup and restore for Joomla — database, files, and configuration. icon-archive - Tools -
https://mokoconsulting.tech/support/products/mokosuiteclientbackup
- https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientBackup/raw/branch/dev/updates.xml + Platform + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/raw/branch/main/updates.xml
+ + + + MokoSuiteCRM + pkg_mokosuitecrm + package + Layer 1 — Contacts, deals pipeline, activities, e-signature, email integration, helpdesk. + icon-address-book + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM/raw/branch/main/updates.xml + + + MokoSuiteERP + pkg_mokosuiteerp + package + Layer 2 — Products, orders, invoicing, inventory, warehouses, accounting, payments. + icon-briefcase + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteERP/raw/branch/main/updates.xml + + + MokoSuiteShop + pkg_mokosuiteshop + package + Layer 3 — Product catalog, shopping cart, checkout, coupons. Requires MokoSuiteERP. + icon-shopping-cart + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteShop/raw/branch/main/updates.xml + + + MokoSuitePOS + pkg_mokosuitepos + package + Layer 3 — Touch-screen POS, multi-terminal, cash register, receipt printing. + icon-calculator + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuitePOS/raw/branch/main/updates.xml + + + MokoSuiteMRP + pkg_mokosuitemrp + package + Layer 3 — BOM, manufacturing orders, workstation management, production scheduling. + icon-cog + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteMRP/raw/branch/main/updates.xml + + + MokoSuiteHRM + pkg_mokosuitehrm + package + Layer 3 — Human Resource Management: employees, leave, expenses, payroll, recruiting. + icon-users + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteHRM/raw/branch/main/updates.xml + + + MokoSuiteRestaurant + pkg_mokosuiterestaurant + package + Layer 4 — Floor plan, table management, kitchen display, split bills, online ordering. + icon-utensils + Industry + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteRestaurant/raw/branch/main/updates.xml + + + MokoSuiteChild + pkg_mokosuitechild + package + Layer 2 — Child Care Management: enrollment, attendance, billing, parent portal. + icon-child + Industry + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteChild/raw/branch/main/updates.xml + + + MokoSuiteNPO + pkg_mokosuitenpo + package + Nonprofit management: donors, donations, campaigns, grants, volunteers, events. + icon-heart + Industry + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteNPO/raw/branch/main/updates.xml + + + MokoSuiteField + pkg_mokosuitefield + package + Field Service — dispatch, work orders, scheduling, mobile tech, plumbing/HVAC. + icon-wrench + Industry + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/raw/branch/main/updates.xml + + + MokoSuiteCreate + pkg_mokosuitecreate + package + Layer 2 — Creative Agency: projects, tasks, timesheets, client proofing. + icon-paint-brush + Industry + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCreate/raw/branch/main/updates.xml + + + + + MokoSuiteForms + pkg_mokosuiteforms + package + Form builder — custom forms, submissions, notifications, and data exports. + icon-list-alt + Content + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteForms/raw/branch/main/updates.xml + + + MokoSuiteCommunity + pkg_mokosuitecommunity + package + Community profiles, connections, and activity streams for Joomla. + icon-users + Content + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCommunity/raw/branch/main/updates.xml + + + MokoSuiteCross + pkg_mokosuitecross + package + Cross-posting Joomla content to social media, email marketing, and chat platforms. + icon-share-alt + Content + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCross/raw/branch/main/updates.xml + + + MokoSuiteOpenGraph + pkg_mokosuiteopengraph + package + Open Graph, Twitter Card, JSON-LD structured data, and social sharing meta tags. + icon-share-alt + SEO + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/raw/branch/main/updates.xml + + + MokoSuiteStoreLocator + pkg_mokosuitestorelocator + package + Interactive map, location search, and admin management for store locations. + icon-map-marker-alt + Content + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/raw/branch/main/updates.xml + + + MokoJoomHero mod_mokojoomhero module - Random hero image module from a configurable folder. + Hero module — image slideshow, video backgrounds, solid color/gradient, parallax. icon-image Modules -
https://mokoconsulting.tech/support/products/mokojoomhero
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/dev/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/main/updates.xml
+ + - MokoJoomCommunity - pkg_mokojoomcommunity - package - Community Builder integration package with custom fields and user management. - icon-users - Community -
https://mokoconsulting.tech/support/products/mokojoomcommunity
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCommunity/raw/branch/dev/updates.xml -
- - MokoJoomCross - plg_system_mokojoomcross - plugin - Cross-extension integration plugin for Joomla component interoperability. - icon-link - Plugins -
https://mokoconsulting.tech/support/products/mokojoomcross
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/raw/branch/dev/updates.xml -
- - MokoJoomStoreLocator - mod_mokojoomstorelocator - module - Store locator module with Google Maps integration and search. - icon-map-marker-alt - Modules -
https://mokoconsulting.tech/support/products/mokojoomstorelocator
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/raw/branch/dev/updates.xml -
- - DPCalendar API - mokodpcalendarapi - plugin - Web Services plugin exposing DPCalendar events and calendars via REST API. - icon-calendar - Plugins -
https://mokoconsulting.tech/support/products/mokodpcalendarapi
- https://git.mokoconsulting.tech/MokoConsulting/MokoDPCalendarAPI/raw/branch/dev/updates.xml -
- - Gallery Calendar - mokogallerycalendar - plugin - JoomGallery and DPCalendar integration — link galleries to events. - icon-images - Plugins -
https://mokoconsulting.tech/support/products/mokogallerycalendar
- https://git.mokoconsulting.tech/MokoConsulting/MokoGalleryCalendar/raw/branch/dev/updates.xml + MokoOnyx + mokoonyx + template + Modern Joomla site template with dark mode, custom layouts, and MokoSuite integration. + icon-paint-brush + Templates + https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml
diff --git a/source/packages/com_mokosuiteclient/admin/config.xml b/source/packages/com_mokosuiteclient/admin/config.xml index 82ebee95..c00ca1e4 100644 --- a/source/packages/com_mokosuiteclient/admin/config.xml +++ b/source/packages/com_mokosuiteclient/admin/config.xml @@ -1,17 +1,17 @@
- + hint="MokoSuite" />
-
+
@@ -40,13 +40,13 @@ label="ntfy Server URL" description="Full URL to your ntfy server." showon="ntfy_enabled:1" /> - -
-
- - - - - - - - - - - +
+ + + + + + + +
-
- - - - - - - - - - +
+ + + +
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')); diff --git a/source/packages/com_mokosuiteclient/admin/src/Helper/ConditionsHelper.php b/source/packages/com_mokosuiteclient/admin/src/Helper/ConditionsHelper.php new file mode 100644 index 00000000..de825860 --- /dev/null +++ b/source/packages/com_mokosuiteclient/admin/src/Helper/ConditionsHelper.php @@ -0,0 +1,525 @@ + + */ + 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)); + } +} diff --git a/source/packages/com_mokosuiteclient/admin/src/Helper/SupportPinHelper.php b/source/packages/com_mokosuiteclient/admin/src/Helper/SupportPinHelper.php new file mode 100644 index 00000000..73215741 --- /dev/null +++ b/source/packages/com_mokosuiteclient/admin/src/Helper/SupportPinHelper.php @@ -0,0 +1,286 @@ + 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 .= ''; + $html .= ''; + $html .= '' . $escaped . ''; + $html .= ''; + } + else + { + $html .= ''; + $html .= '' . $escaped . ''; + } + } + else + { + if ($context === 'cache') + { + $html .= ''; + $html .= ''; + $html .= 'PIN'; + $html .= ''; + } + else + { + $html .= ''; + } + } + + return $html; + } + + /** + * Render shared JS for PIN copy and request functionality. + * + * @return string +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.']; + } +} diff --git a/source/packages/com_mokosuiteclient/admin/src/Model/DashboardModel.php b/source/packages/com_mokosuiteclient/admin/src/Model/DashboardModel.php index 48c28a7d..d5b2dc9f 100644 --- a/source/packages/com_mokosuiteclient/admin/src/Model/DashboardModel.php +++ b/source/packages/com_mokosuiteclient/admin/src/Model/DashboardModel.php @@ -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, ]; } diff --git a/source/packages/com_mokosuiteclient/admin/src/Model/ExtensionsModel.php b/source/packages/com_mokosuiteclient/admin/src/Model/ExtensionsModel.php index bfd02aa8..cf8915b0 100644 --- a/source/packages/com_mokosuiteclient/admin/src/Model/ExtensionsModel.php +++ b/source/packages/com_mokosuiteclient/admin/src/Model/ExtensionsModel.php @@ -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; } diff --git a/source/packages/com_mokosuiteclient/admin/src/Service/AttachmentService.php b/source/packages/com_mokosuiteclient/admin/src/Service/AttachmentService.php deleted file mode 100644 index e150db3c..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/Service/AttachmentService.php +++ /dev/null @@ -1,183 +0,0 @@ - [$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'; - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/Service/AutomationEngine.php b/source/packages/com_mokosuiteclient/admin/src/Service/AutomationEngine.php deleted file mode 100644 index 37d7773c..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/Service/AutomationEngine.php +++ /dev/null @@ -1,280 +0,0 @@ -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'); - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/Service/NotificationService.php b/source/packages/com_mokosuiteclient/admin/src/Service/NotificationService.php deleted file mode 100644 index caba2318..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/Service/NotificationService.php +++ /dev/null @@ -1,581 +0,0 @@ -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'); - } - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Automation/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Automation/HtmlView.php index a8ea1657..03867826 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Automation/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Automation/HtmlView.php @@ -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'); diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Canned/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Canned/HtmlView.php deleted file mode 100644 index 87d54425..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/View/Canned/HtmlView.php +++ /dev/null @@ -1,33 +0,0 @@ -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); - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Categories/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Categories/HtmlView.php index d41ddb75..134bd609 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Categories/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Categories/HtmlView.php @@ -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'); diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Cleanup/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Cleanup/HtmlView.php index 0c033482..367527b2 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Cleanup/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Cleanup/HtmlView.php @@ -18,6 +18,7 @@ class HtmlView extends BaseHtmlView ToolbarHelper::title('Cache & 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'); diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php index 322ba7b1..dae6aef9 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php @@ -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'); } } diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Database/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Database/HtmlView.php index 0483c02a..c3761f5d 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Database/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Database/HtmlView.php @@ -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'); diff --git a/source/packages/com_mokosuiteclient/admin/src/View/ErpReports/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/ErpReports/HtmlView.php index 7d9f706c..7e53c01d 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/ErpReports/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/ErpReports/HtmlView.php @@ -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]); diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Extensions/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Extensions/HtmlView.php index e81067a8..ebe7e984 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Extensions/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Extensions/HtmlView.php @@ -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'); } } diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Htaccess/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Htaccess/HtmlView.php index b314cdb2..0c214e4b 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Htaccess/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Htaccess/HtmlView.php @@ -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'); } } diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Privacy/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Privacy/HtmlView.php index 736baffa..23215ca7 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Privacy/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Privacy/HtmlView.php @@ -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'); } } diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Waflog/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Waflog/HtmlView.php index f8a84dbe..59e39596 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Waflog/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Waflog/HtmlView.php @@ -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'); } } diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/canned/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/canned/default.php deleted file mode 100644 index 14273e52..00000000 --- a/source/packages/com_mokosuiteclient/admin/tmpl/canned/default.php +++ /dev/null @@ -1,227 +0,0 @@ -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; -} -?> - -
-
-
-

Canned Responses

- -
- -
- -
- -
-
-
-
-
- - title); ?> - category_id) && isset($catMap[$r->category_id])): ?> - category_id]); ?> - -
-

body), 0, 150)); ?>

-
- -
-
-
- - - -
No canned responses yet. Click "Add Response" to create one.
- -
-
- - - - - diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php index c627f3a7..ba089711 100644 --- a/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php +++ b/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php @@ -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 escape($siteInfo->sitename); ?> MokoSuite escape($siteInfo->mokosuiteclient_version); ?> + !empty($this->supportPinAvailable), 'pin' => $this->supportPin ?? ''], + $token, 'dashboard' + ); ?> supportPin)): ?> - escape($this->supportPin); ?> - - - - @@ -111,6 +121,14 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action Clear Cache + + +
@@ -195,11 +213,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
protected): ?> - configure_only): ?> - - enabled ? Text::_('COM_MOKOSUITECLIENT_ENABLED') : Text::_('COM_MOKOSUITECLIENT_DISABLED'); ?> - - + extension_id): ?>
- type === 'plugin'): ?> + extension_id && $plugin->type === 'plugin'): ?>
@@ -229,6 +243,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
+
@@ -238,6 +253,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
+
@@ -308,6 +324,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
+
@@ -335,6 +352,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
+
@@ -445,3 +463,5 @@ document.addEventListener('DOMContentLoaded', function() { } }); + + diff --git a/source/packages/com_mokosuiteclient/api/src/Controller/TicketsController.php b/source/packages/com_mokosuiteclient/api/src/Controller/TicketsController.php deleted file mode 100644 index 6250944d..00000000 --- a/source/packages/com_mokosuiteclient/api/src/Controller/TicketsController.php +++ /dev/null @@ -1,313 +0,0 @@ -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(); - } -} diff --git a/source/packages/com_mokosuiteclient/media/js/dashboard.js b/source/packages/com_mokosuiteclient/media/js/dashboard.js index 065fde22..88ae33c0 100644 --- a/source/packages/com_mokosuiteclient/media/js/dashboard.js +++ b/source/packages/com_mokosuiteclient/media/js/dashboard.js @@ -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); diff --git a/source/packages/com_mokosuiteclient/mokosuiteclient.xml b/source/packages/com_mokosuiteclient/mokosuiteclient.xml index 30006a05..ff73fdeb 100644 --- a/source/packages/com_mokosuiteclient/mokosuiteclient.xml +++ b/source/packages/com_mokosuiteclient/mokosuiteclient.xml @@ -20,7 +20,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 MokoSuiteClient admin dashboard and REST API. Provides a control panel for managing MokoSuiteClient feature plugins, site health monitoring, and remote management endpoints. Moko\Component\MokoSuiteClient @@ -42,7 +42,6 @@ COM_MOKOSUITECLIENT_MENU_DASHBOARD COM_MOKOSUITECLIENT_MENU_EXTENSIONS - COM_MOKOSUITECLIENT_MENU_TICKETS COM_MOKOSUITECLIENT_MENU_HTACCESS COM_MOKOSUITECLIENT_MENU_PRIVACY COM_MOKOSUITECLIENT_MENU_WAFLOG @@ -75,10 +74,6 @@ tmpl - - admin/sql/install.mysql.sql - - src diff --git a/source/packages/com_mokosuiteclient/site/src/View/Ticket/HtmlView.php b/source/packages/com_mokosuiteclient/site/src/View/Ticket/HtmlView.php deleted file mode 100644 index b0240989..00000000 --- a/source/packages/com_mokosuiteclient/site/src/View/Ticket/HtmlView.php +++ /dev/null @@ -1,84 +0,0 @@ -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); - } -} diff --git a/source/packages/com_mokosuiteclient/site/src/View/Tickets/HtmlView.php b/source/packages/com_mokosuiteclient/site/src/View/Tickets/HtmlView.php deleted file mode 100644 index cd97727b..00000000 --- a/source/packages/com_mokosuiteclient/site/src/View/Tickets/HtmlView.php +++ /dev/null @@ -1,75 +0,0 @@ -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); - } -} diff --git a/source/packages/com_mokosuiteclient/site/tmpl/ticket/default.php b/source/packages/com_mokosuiteclient/site/tmpl/ticket/default.php deleted file mode 100644 index b0971855..00000000 --- a/source/packages/com_mokosuiteclient/site/tmpl/ticket/default.php +++ /dev/null @@ -1,241 +0,0 @@ -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', -]; -?> - -
- - -
- -
- - -
-
-
-
-

#id; ?> — subject); ?>

- - category_title ?? 'General'); ?> - · created, 'M d, Y H:i'); ?> - · priority); ?> - - · By: created_by_name); ?> - - -
- - status] ?? $t->status; ?> - -
-
-
- - -
-
- created_by_name); ?> - created, 'M d, Y H:i'); ?> -
-
body)); ?>
-
- - - replies as $reply): ?> - user_id !== (int) $t->created_by); - $isInternal = (int) $reply->is_internal; - ?> -
-
-
- user_name ?? 'Support'); ?> - Staff - Internal Note - created, 'M d, Y H:i'); ?> -
-
-
body)); ?>
-
- - - - status, ['closed'])): ?> -
-
-
Reply
-
- - - -
- - - - -
-
-
-
- status === 'closed'): ?> -
- This ticket is closed. Open a new ticket if you need further help. -
- -
- - - -
- -
-
Details
-
-
-
Status
-
status] ?? $t->status; ?>
-
Priority
-
priority); ?>
-
Category
-
category_title ?? '—'); ?>
-
Submitted By
-
created_by_name); ?>
created_by_email ?? ''); ?>
-
Assigned To
-
assigned_to_name ?? 'Unassigned'); ?>
-
Created
-
created, 'M d H:i'); ?>
-
Replies
-
replies); ?>
-
-
-
- - -
-
Change Status
-
- 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?> - status): ?> - - - -
-
- - - -
-
Assign
-
- -
-
- -
- -
-
- - diff --git a/source/packages/com_mokosuiteclient/site/tmpl/tickets/default.php b/source/packages/com_mokosuiteclient/site/tmpl/tickets/default.php deleted file mode 100644 index 6537c29d..00000000 --- a/source/packages/com_mokosuiteclient/site/tmpl/tickets/default.php +++ /dev/null @@ -1,83 +0,0 @@ -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', -]; -?> - -
-
-

-
- - New Ticket - - -
- - - -
- -
-
- - -
- - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#SubjectStatusPriorityCategorySubmitted ByAssigned ToDate
id; ?>subject, 0, 60)); ?>status] ?? $t->status; ?>priority); ?>category_title ?? '—'); ?>created_by_name ?? ''); ?>assigned_to_name ?? 'Unassigned'); ?>created, 'M d, Y'); ?>
-
- -
diff --git a/source/packages/com_mokosuiteclient/site/tmpl/tickets/submit.php b/source/packages/com_mokosuiteclient/site/tmpl/tickets/submit.php deleted file mode 100644 index 762fce50..00000000 --- a/source/packages/com_mokosuiteclient/site/tmpl/tickets/submit.php +++ /dev/null @@ -1,204 +0,0 @@ -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) {} -?> - -
-

Submit a Support Request

- - - - - - - -
-
-
-
Ticket Details
-
-
- - -
-
-
- - -
-
- - -
-
-
- - -
- -
- - - My Tickets - -
-
-
-
-
-
- - diff --git a/source/packages/mod_mokosuiteclient_cache/mod_mokosuiteclient_cache.xml b/source/packages/mod_mokosuiteclient_cache/mod_mokosuiteclient_cache.xml index a25bb47f..54e8fa92 100644 --- a/source/packages/mod_mokosuiteclient_cache/mod_mokosuiteclient_cache.xml +++ b/source/packages/mod_mokosuiteclient_cache/mod_mokosuiteclient_cache.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 MOD_MOKOSUITECLIENT_CACHE_DESC Moko\Module\MokoSuiteClientCache diff --git a/source/packages/mod_mokosuiteclient_cache/src/Dispatcher/Dispatcher.php b/source/packages/mod_mokosuiteclient_cache/src/Dispatcher/Dispatcher.php index 75e2f7bb..8ef4f512 100644 --- a/source/packages/mod_mokosuiteclient_cache/src/Dispatcher/Dispatcher.php +++ b/source/packages/mod_mokosuiteclient_cache/src/Dispatcher/Dispatcher.php @@ -1,17 +1,34 @@ 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; } diff --git a/source/packages/mod_mokosuiteclient_cache/tmpl/default.php b/source/packages/mod_mokosuiteclient_cache/tmpl/default.php index c1614b1e..28d1f806 100644 --- a/source/packages/mod_mokosuiteclient_cache/tmpl/default.php +++ b/source/packages/mod_mokosuiteclient_cache/tmpl/default.php @@ -1,40 +1,57 @@ -
- -
-
-
-
- - -
- -
-
Temp
-
+
+ + + Site + + + 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; ?> +
+ + +
+
+ diff --git a/source/packages/mod_mokosuiteclient_categories/mod_mokosuiteclient_categories.xml b/source/packages/mod_mokosuiteclient_categories/mod_mokosuiteclient_categories.xml index 6d14998b..8cd87e23 100644 --- a/source/packages/mod_mokosuiteclient_categories/mod_mokosuiteclient_categories.xml +++ b/source/packages/mod_mokosuiteclient_categories/mod_mokosuiteclient_categories.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 MOD_MOKOSUITECLIENT_CATEGORIES_DESC Moko\Module\MokoSuiteClientCategories diff --git a/source/packages/mod_mokosuiteclient_cpanel/mod_mokosuiteclient_cpanel.xml b/source/packages/mod_mokosuiteclient_cpanel/mod_mokosuiteclient_cpanel.xml index 60eaa47a..caaea53b 100644 --- a/source/packages/mod_mokosuiteclient_cpanel/mod_mokosuiteclient_cpanel.xml +++ b/source/packages/mod_mokosuiteclient_cpanel/mod_mokosuiteclient_cpanel.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 MOD_MOKOSUITECLIENT_CPANEL_DESC Moko\Module\MokoSuiteClientCpanel diff --git a/source/packages/mod_mokosuiteclient_cpanel/src/Dispatcher/Dispatcher.php b/source/packages/mod_mokosuiteclient_cpanel/src/Dispatcher/Dispatcher.php index a3e5ffcf..106a69cb 100644 --- a/source/packages/mod_mokosuiteclient_cpanel/src/Dispatcher/Dispatcher.php +++ b/source/packages/mod_mokosuiteclient_cpanel/src/Dispatcher/Dispatcher.php @@ -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; } diff --git a/source/packages/mod_mokosuiteclient_cpanel/src/Helper/CpanelHelper.php b/source/packages/mod_mokosuiteclient_cpanel/src/Helper/CpanelHelper.php index 2c2bc498..b87d1de5 100644 --- a/source/packages/mod_mokosuiteclient_cpanel/src/Helper/CpanelHelper.php +++ b/source/packages/mod_mokosuiteclient_cpanel/src/Helper/CpanelHelper.php @@ -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'), ]; diff --git a/source/packages/mod_mokosuiteclient_cpanel/tmpl/default.php b/source/packages/mod_mokosuiteclient_cpanel/tmpl/default.php index 29412f9e..ffa65e9c 100644 --- a/source/packages/mod_mokosuiteclient_cpanel/tmpl/default.php +++ b/source/packages/mod_mokosuiteclient_cpanel/tmpl/default.php @@ -1,9 +1,6 @@ 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[] = 'Debug'; +if (!empty($siteInfo->offline)) $statusDots[] = 'Offline'; +if (($counts->updates ?? 0) > 0) $statusDots[] = '' . (int)$counts->updates . ' updates'; ?> -
-
- getIdentity()->authorise('core.manage', 'com_mokosuiteclient'); ?> +
+
- + + + - - - sitename ?? ''); ?> - MokoSuite mokosuiteclient_version ?? ''); ?> - - - - Joomla joomla_version ?? ''); ?> - PHP php_version ?? ''); ?> - db_type ?? ''); ?> - debug)): ?> - Debug ON - - offline)): ?> - Offline + + + + v + + + + !empty($supportPinAvailable), 'pin' => $supportPin ?? ''], + $token, 'cpanel' + ); ?> + - - + + + + +
+ +
+
+
+
+
Environment
+
+ MokoSuite + Joomla + PHP + +
+
+
+
Stats
+
+ articles ?? 0); ?> articles + users ?? 0); ?> users + extensions ?? 0); ?> extensions +
+ +
+
+
+
+ Disk % (free_mb ?? 0) / 1024, 1); ?> GB free) +
+ +
+ 0): ?> +
+
Plugins (/)
+
+ element); + $label = $labels[$el] ?? ucfirst(str_replace('mokosuiteclient_', '', $el)); + $color = $p->enabled ? 'success' : 'secondary'; + ?> + + +
+
+ +
+
+
+ + + diff --git a/source/packages/mod_mokosuiteclient_menu/mod_mokosuiteclient_menu.xml b/source/packages/mod_mokosuiteclient_menu/mod_mokosuiteclient_menu.xml index 6d0d50c6..2e2a52b8 100644 --- a/source/packages/mod_mokosuiteclient_menu/mod_mokosuiteclient_menu.xml +++ b/source/packages/mod_mokosuiteclient_menu/mod_mokosuiteclient_menu.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu. Moko\Module\MokoSuiteClientMenu diff --git a/source/packages/mod_mokosuiteclient_menu/tmpl/default.php b/source/packages/mod_mokosuiteclient_menu/tmpl/default.php index 35cb9322..3bdcb1d2 100644 --- a/source/packages/mod_mokosuiteclient_menu/tmpl/default.php +++ b/source/packages/mod_mokosuiteclient_menu/tmpl/default.php @@ -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;'; ?>
diff --git a/source/packages/plg_system_mokosuiteclient/script.php b/source/packages/plg_system_mokosuiteclient/script.php index f77b21fb..543a89ab 100644 --- a/source/packages/plg_system_mokosuiteclient/script.php +++ b/source/packages/plg_system_mokosuiteclient/script.php @@ -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 diff --git a/source/packages/plg_system_mokosuiteclient/services/provider.php b/source/packages/plg_system_mokosuiteclient/services/provider.php index 8fb66051..2d89ff8f 100644 --- a/source/packages/plg_system_mokosuiteclient/services/provider.php +++ b/source/packages/plg_system_mokosuiteclient/services/provider.php @@ -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 diff --git a/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml b/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml index 43ada9a1..c04b531f 100644 --- a/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml +++ b/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC Moko\Plugin\System\MokoSuiteClientBackup diff --git a/source/packages/plg_system_mokosuiteclient_dbip/mokosuiteclient_dbip.xml b/source/packages/plg_system_mokosuiteclient_dbip/mokosuiteclient_dbip.xml index 8fe8e26f..38f21993 100644 --- a/source/packages/plg_system_mokosuiteclient_dbip/mokosuiteclient_dbip.xml +++ b/source/packages/plg_system_mokosuiteclient_dbip/mokosuiteclient_dbip.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC Moko\Plugin\System\MokoSuiteClientDBIP diff --git a/source/packages/plg_system_mokosuiteclient_dbip/src/Extension/DBIP.php b/source/packages/plg_system_mokosuiteclient_dbip/src/Extension/DBIP.php index bacfe289..c5d3f6c7 100644 --- a/source/packages/plg_system_mokosuiteclient_dbip/src/Extension/DBIP.php +++ b/source/packages/plg_system_mokosuiteclient_dbip/src/Extension/DBIP.php @@ -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 = '#]*)?>(' . $ipv4 . '|' . $ipv6 . ')
#'; + + $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 . ' ' . $escaped . ''; + }, $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; + } } diff --git a/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml b/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml index b879f72c..a065807a 100644 --- a/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml +++ b/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC Moko\Plugin\System\MokoSuiteClientDevTools diff --git a/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml b/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml index de3d08c0..f184d6e2 100644 --- a/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml +++ b/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC Moko\Plugin\System\MokoSuiteClientFirewall diff --git a/source/packages/plg_system_mokosuiteclient_license/language/en-GB/plg_system_mokosuiteclient_license.ini b/source/packages/plg_system_mokosuiteclient_license/language/en-GB/plg_system_mokosuiteclient_license.ini new file mode 100644 index 00000000..47f1ef54 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_license/language/en-GB/plg_system_mokosuiteclient_license.ini @@ -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." diff --git a/source/packages/plg_system_mokosuiteclient_license/language/en-GB/plg_system_mokosuiteclient_license.sys.ini b/source/packages/plg_system_mokosuiteclient_license/language/en-GB/plg_system_mokosuiteclient_license.sys.ini new file mode 100644 index 00000000..fc1f126d --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_license/language/en-GB/plg_system_mokosuiteclient_license.sys.ini @@ -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." diff --git a/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml b/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml index 84c28249..b90bfe73 100644 --- a/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml +++ b/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC Moko\Plugin\System\MokoSuiteClientLicense srcserviceslanguage diff --git a/source/packages/plg_system_mokosuiteclient_license/src/Extension/License.php b/source/packages/plg_system_mokosuiteclient_license/src/Extension/License.php new file mode 100644 index 00000000..4d4e6b2e --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_license/src/Extension/License.php @@ -0,0 +1,32 @@ +GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC Moko\Plugin\System\MokoSuiteClientOffline diff --git a/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml b/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml index 80ffe40f..34ed8562 100644 --- a/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml +++ b/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC Moko\Plugin\System\MokoSuiteClientTenant diff --git a/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml b/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml index 90eacc99..804671fd 100644 --- a/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml +++ b/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml @@ -12,7 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 PLG_TASK_MOKOSUITECLIENTDEMO_DESC Moko\Plugin\Task\MokoSuiteClientDemo diff --git a/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php b/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php index 4beb1702..5f9a2e5c 100644 --- a/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php +++ b/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php @@ -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 */ diff --git a/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml b/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml index 83a63190..831669b2 100644 --- a/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml +++ b/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml @@ -12,7 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 PLG_TASK_MOKOSUITECLIENTSYNC_DESC Moko\Plugin\Task\MokoSuiteClientSync diff --git a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php index 29c31482..528e73ff 100644 --- a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php +++ b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php @@ -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 */ diff --git a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php index 62b64f55..24901bb0 100644 --- a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php +++ b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php @@ -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 */ diff --git a/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml b/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml index 380fd970..3be77ca0 100644 --- a/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml +++ b/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.48.52 Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info. Moko\Plugin\WebServices\MokoSuiteClient diff --git a/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php b/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php index 7dd72165..faf2cad0 100644 --- a/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php +++ b/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php @@ -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'] - ) - ); } } diff --git a/source/pkg_mokosuiteclient.xml b/source/pkg_mokosuiteclient.xml index cfef48c6..81cc241e 100644 --- a/source/pkg_mokosuiteclient.xml +++ b/source/pkg_mokosuiteclient.xml @@ -2,14 +2,14 @@ Package - MokoSuiteClient mokosuiteclient - 02.47.48 + 02.48.52 2026-06-02 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GNU General Public License version 3 or later; see LICENSE - MokoSuiteClient site management suite — admin dashboard, security firewall, tenant restrictions, health monitoring, developer tools, and REST API. + MokoSuiteClient site management suite: admin dashboard, security firewall, tenant restrictions, health monitoring, developer tools, and REST API. true script.php @@ -20,6 +20,9 @@ plg_system_mokosuiteclient_tenant.zip plg_system_mokosuiteclient_devtools.zip plg_system_mokosuiteclient_offline.zip + plg_system_mokosuiteclient_backup.zip + plg_system_mokosuiteclient_dbip.zip + plg_system_mokosuiteclient_license.zip com_mokosuiteclient.zip mod_mokosuiteclient_cpanel.zip mod_mokosuiteclient_menu.zip diff --git a/source/script.php b/source/script.php index a332c0b3..35f28ec0 100644 --- a/source/script.php +++ b/source/script.php @@ -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. *