feat: Privacy Guard - data compliance, consent, retention (#150)

Admin Privacy Dashboard (view=privacy):
- Data subject requests list with approve/deny actions
- Retention policies table with active status
- Summary cards (pending, total, consent entries, policies)
- Export user data as JSON download
- ACL: core.admin only

PrivacyModel:
- createRequest/processRequest for export/delete/anonymize
- exportUserData: profile, articles, action logs, tickets, replies,
  consent history, Community Builder profile
- anonymizeUserData: replace PII, block account, clear logs
- deleteUserData: full hard delete (anonymize first, then remove)
- logConsent/getUserConsent: consent tracking
- enforceRetentionPolicies: action_logs, waf_logs, sessions,
  inactive_users, closed_tickets (scheduled task ready)
- getDashboardSummary

Frontend Self-Service (/index.php?option=com_mokowaas&view=privacy):
- Download My Data, Anonymize, Delete Account buttons
- Request history table
- Consent history table
- Login required

Database tables:
- #__mokowaas_consent_log
- #__mokowaas_data_requests
- #__mokowaas_retention_policies (5 defaults)

Submenu: MokoWaaS > Privacy Guard

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-03 11:53:54 -05:00
parent 4c728ef7b6
commit dd20e42cb2
10 changed files with 1104 additions and 0 deletions
@@ -12,4 +12,5 @@ COM_MOKOWAAS_MENU_UPDATES="Joomla Updates"
COM_MOKOWAAS_MENU_CHECKIN="Global Check-in"
COM_MOKOWAAS_MENU_TICKETS="Helpdesk"
COM_MOKOWAAS_MENU_HTACCESS=".htaccess Maker"
COM_MOKOWAAS_MENU_PRIVACY="Privacy Guard"
COM_MOKOWAAS_MENU_CACHE="Cache Management"
@@ -85,3 +85,51 @@ INSERT IGNORE INTO `#__mokowaas_ticket_categories` (`id`, `title`, `alias`, `des
(3, 'Feature Request', 'feature-request', 'Request a new feature or enhancement', 1440, 10080, 3),
(4, 'Billing', 'billing', 'Billing, invoicing, and payment questions', 240, 1440, 4),
(5, 'Urgent / Outage', 'urgent-outage', 'Site down or critical issue', 60, 240, 5);
--
-- Privacy Guard Tables
--
CREATE TABLE IF NOT EXISTS `#__mokowaas_consent_log` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`category` VARCHAR(50) NOT NULL,
`action` ENUM('granted','revoked') NOT NULL,
`ip_address` VARCHAR(45) NOT NULL DEFAULT '',
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_category` (`category`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_data_requests` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`type` ENUM('export','delete','anonymize') NOT NULL,
`status` ENUM('pending','processing','completed','denied') NOT NULL DEFAULT 'pending',
`notes` TEXT,
`processed_by` INT DEFAULT NULL,
`created` DATETIME NOT NULL,
`processed` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_retention_policies` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`content_type` VARCHAR(100) NOT NULL,
`retention_days` INT UNSIGNED NOT NULL DEFAULT 365,
`action` ENUM('anonymize','delete','archive') NOT NULL DEFAULT 'anonymize',
`enabled` TINYINT NOT NULL DEFAULT 1,
`description` VARCHAR(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Default retention policies
INSERT IGNORE INTO `#__mokowaas_retention_policies` (`id`, `content_type`, `retention_days`, `action`, `enabled`, `description`) VALUES
(1, 'action_logs', 90, 'delete', 1, 'Delete action log entries older than 90 days'),
(2, 'waf_logs', 30, 'delete', 1, 'Delete WAF block logs older than 30 days'),
(3, 'sessions', 7, 'delete', 1, 'Purge expired sessions older than 7 days'),
(4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'),
(5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)');
@@ -29,6 +29,7 @@ class DisplayController extends BaseController
'htaccess' => 'mokowaas.htaccess',
'tickets' => 'mokowaas.tickets',
'ticket' => 'mokowaas.tickets',
'privacy' => 'core.admin',
];
public function display($cachable = false, $urlparams = [])
@@ -268,6 +269,44 @@ class DisplayController extends BaseController
}
}
// ==================================================================
// Privacy Guard
// ==================================================================
public function processDataRequest()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
}
$input = Factory::getApplication()->getInput();
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$this->jsonResponse($model->processRequest(
$input->getInt('request_id', 0),
$input->getString('action', 'deny')
));
}
public function exportUserData()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
}
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$this->jsonResponse($model->exportUserData(
Factory::getApplication()->getInput()->getInt('user_id', 0)
));
}
// ==================================================================
// Importers
// ==================================================================
@@ -0,0 +1,590 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class PrivacyModel extends BaseDatabaseModel
{
/**
* Get all pending data requests.
*/
public function getDataRequests(string $filterStatus = ''): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('r') . '.*',
$db->quoteName('u.name', 'user_name'),
$db->quoteName('u.email', 'user_email'),
$db->quoteName('u.username'),
$db->quoteName('p.name', 'processed_by_name'),
])
->from($db->quoteName('#__mokowaas_data_requests', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->leftJoin($db->quoteName('#__users', 'p') . ' ON p.id = r.processed_by');
if ($filterStatus)
{
$query->where($db->quoteName('r.status') . ' = ' . $db->quote($filterStatus));
}
$query->order($db->quoteName('r.created') . ' DESC')->setLimit(50);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Create a data request (from admin or user self-service).
*/
public function createRequest(int $userId, string $type, string $notes = ''): array
{
$validTypes = ['export', 'delete', 'anonymize'];
if (!\in_array($type, $validTypes, true))
{
return ['success' => false, 'message' => 'Invalid request type.'];
}
try
{
$db = $this->getDatabase();
$row = (object) [
'user_id' => $userId,
'type' => $type,
'status' => 'pending',
'notes' => $notes,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_data_requests', $row, 'id');
return ['success' => true, 'message' => ucfirst($type) . ' request #' . $row->id . ' created.', 'id' => (int) $row->id];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Process a data request (approve and execute).
*/
public function processRequest(int $requestId, string $action): array
{
$db = $this->getDatabase();
try
{
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_data_requests'))
->where($db->quoteName('id') . ' = ' . $requestId)
);
$request = $db->loadObject();
if (!$request)
{
return ['success' => false, 'message' => 'Request not found.'];
}
if ($action === 'deny')
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_data_requests'))
->set($db->quoteName('status') . ' = ' . $db->quote('denied'))
->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id)
->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('id') . ' = ' . $requestId)
)->execute();
return ['success' => true, 'message' => 'Request denied.'];
}
// Mark as processing
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_data_requests'))
->set($db->quoteName('status') . ' = ' . $db->quote('processing'))
->where($db->quoteName('id') . ' = ' . $requestId)
)->execute();
// Execute the request
$result = null;
switch ($request->type)
{
case 'export':
$result = $this->exportUserData((int) $request->user_id);
break;
case 'delete':
$result = $this->deleteUserData((int) $request->user_id);
break;
case 'anonymize':
$result = $this->anonymizeUserData((int) $request->user_id);
break;
}
// Mark completed
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_data_requests'))
->set($db->quoteName('status') . ' = ' . $db->quote('completed'))
->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id)
->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('id') . ' = ' . $requestId)
)->execute();
return $result ?? ['success' => true, 'message' => 'Request processed.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Processing failed: ' . $e->getMessage()];
}
}
/**
* Export all data for a user as a structured array.
*/
public function exportUserData(int $userId): array
{
$db = $this->getDatabase();
$data = ['user_id' => $userId, 'exported' => gmdate('Y-m-d\TH:i:s\Z')];
try
{
// User profile
$db->setQuery(
$db->getQuery(true)
->select(['id', 'name', 'username', 'email', 'registerDate', 'lastvisitDate', 'params'])
->from($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = ' . $userId)
);
$data['profile'] = $db->loadObject();
// Content (articles)
$db->setQuery(
$db->getQuery(true)
->select(['id', 'title', 'alias', 'created', 'modified', 'hits'])
->from($db->quoteName('#__content'))
->where($db->quoteName('created_by') . ' = ' . $userId)
);
$data['articles'] = $db->loadObjectList() ?: [];
// Action logs
$db->setQuery(
$db->getQuery(true)
->select(['message', 'log_date', 'ip_address'])
->from($db->quoteName('#__action_logs'))
->where($db->quoteName('user_id') . ' = ' . $userId)
->order('log_date DESC')
->setLimit(100)
);
$data['action_logs'] = $db->loadObjectList() ?: [];
// Support tickets
$db->setQuery(
$db->getQuery(true)
->select(['id', 'subject', 'body', 'status', 'priority', 'created'])
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
);
$data['tickets'] = $db->loadObjectList() ?: [];
// Ticket replies
$db->setQuery(
$db->getQuery(true)
->select(['r.id', 'r.ticket_id', 'r.body', 'r.created'])
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
->where($db->quoteName('r.user_id') . ' = ' . $userId)
);
$data['ticket_replies'] = $db->loadObjectList() ?: [];
// Consent log
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . $userId)
->order('created ASC')
);
$data['consent_history'] = $db->loadObjectList() ?: [];
// Community Builder profile (if table exists)
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%'));
if ($db->loadResult())
{
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__comprofiler'))
->where($db->quoteName('user_id') . ' = ' . $userId)
);
$data['community_builder'] = $db->loadObject();
}
}
catch (\Throwable $e) {}
return ['success' => true, 'message' => 'Data exported.', 'data' => $data];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Export failed: ' . $e->getMessage()];
}
}
/**
* Anonymize a user's data (GDPR right to be forgotten — soft).
*/
public function anonymizeUserData(int $userId): array
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
$anon = 'Anonymous User #' . $userId;
try
{
// Anonymize user record
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__users'))
->set([
$db->quoteName('name') . ' = ' . $db->quote($anon),
$db->quoteName('username') . ' = ' . $db->quote('anon_' . $userId),
$db->quoteName('email') . ' = ' . $db->quote('anon_' . $userId . '@deleted.local'),
$db->quoteName('password') . ' = ' . $db->quote(''),
$db->quoteName('block') . ' = 1',
$db->quoteName('params') . ' = ' . $db->quote('{}'),
])
->where($db->quoteName('id') . ' = ' . $userId)
)->execute();
// Anonymize article authorship
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('created_by_alias') . ' = ' . $db->quote($anon))
->where($db->quoteName('created_by') . ' = ' . $userId)
)->execute();
// Delete action logs
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__action_logs'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
// Anonymize ticket replies
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_ticket_replies'))
->set($db->quoteName('body') . ' = ' . $db->quote('[Content removed per data request]'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
// Community Builder
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%'));
if ($db->loadResult())
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__comprofiler'))
->set([
$db->quoteName('firstname') . ' = ' . $db->quote('Anonymous'),
$db->quoteName('lastname') . ' = ' . $db->quote('User'),
$db->quoteName('middlename') . ' = ' . $db->quote(''),
])
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
}
}
catch (\Throwable $e) {}
// Log the anonymization
$this->logConsent($userId, 'account_anonymized', 'granted');
return ['success' => true, 'message' => 'User #' . $userId . ' data anonymized.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Anonymization failed: ' . $e->getMessage()];
}
}
/**
* Delete a user's data completely (hard delete).
*/
public function deleteUserData(int $userId): array
{
$result = $this->anonymizeUserData($userId);
if (!$result['success'])
{
return $result;
}
$db = $this->getDatabase();
try
{
// Delete tickets and replies
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
);
$ticketIds = $db->loadColumn() ?: [];
if (!empty($ticketIds))
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_ticket_replies'))
->where($db->quoteName('ticket_id') . ' IN (' . implode(',', $ticketIds) . ')')
)->execute();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
)->execute();
}
// Delete consent log
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
// Delete user record
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = ' . $userId)
)->execute();
return ['success' => true, 'message' => 'User #' . $userId . ' data permanently deleted.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Deletion failed: ' . $e->getMessage()];
}
}
// ==================================================================
// Consent Management
// ==================================================================
/**
* Get consent status for a user.
*/
public function getUserConsent(int $userId): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . $userId)
->order($db->quoteName('created') . ' DESC')
);
return $db->loadObjectList() ?: [];
}
/**
* Record a consent action.
*/
public function logConsent(int $userId, string $category, string $action): void
{
$db = $this->getDatabase();
$row = (object) [
'user_id' => $userId,
'category' => $category,
'action' => $action === 'revoked' ? 'revoked' : 'granted',
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_consent_log', $row, 'id');
}
// ==================================================================
// Retention Policy Enforcement
// ==================================================================
/**
* Get all retention policies.
*/
public function getRetentionPolicies(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_retention_policies'))
->order($db->quoteName('id') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Run retention policy enforcement (called by scheduled task).
*/
public function enforceRetentionPolicies(): array
{
$db = $this->getDatabase();
$results = ['policies_run' => 0, 'items_affected' => 0];
$policies = $this->getRetentionPolicies();
foreach ($policies as $policy)
{
if (!(int) $policy->enabled)
{
continue;
}
$cutoff = Factory::getDate('-' . (int) $policy->retention_days . ' days')->toSql();
$count = 0;
try
{
switch ($policy->content_type)
{
case 'action_logs':
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__action_logs'))
->where($db->quoteName('log_date') . ' < ' . $db->quote($cutoff))
)->execute();
$count = $db->getAffectedRows();
break;
case 'waf_logs':
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_waf_log'))
->where($db->quoteName('created') . ' < ' . $db->quote($cutoff))
)->execute();
$count = $db->getAffectedRows();
break;
case 'sessions':
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__session'))
->where($db->quoteName('time') . ' < ' . (int) strtotime($cutoff))
)->execute();
$count = $db->getAffectedRows();
break;
case 'closed_tickets':
if ($policy->action === 'anonymize')
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('body') . ' = ' . $db->quote('[Removed per retention policy]'))
->where($db->quoteName('status') . ' = ' . $db->quote('closed'))
->where($db->quoteName('closed') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('body') . ' != ' . $db->quote('[Removed per retention policy]'))
)->execute();
$count = $db->getAffectedRows();
}
break;
case 'inactive_users':
if ($policy->action === 'anonymize')
{
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__users'))
->where($db->quoteName('lastvisitDate') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('lastvisitDate') . ' != ' . $db->quote('0000-00-00 00:00:00'))
->where($db->quoteName('block') . ' = 0')
->where($db->quoteName('username') . ' NOT LIKE ' . $db->quote('anon_%'))
);
$userIds = $db->loadColumn() ?: [];
foreach ($userIds as $uid)
{
$this->anonymizeUserData((int) $uid);
$count++;
}
}
break;
}
if ($count > 0)
{
$results['policies_run']++;
$results['items_affected'] += $count;
Log::add(\sprintf('Retention: %s — %d items affected', $policy->content_type, $count), Log::INFO, 'mokowaas');
}
}
catch (\Throwable $e)
{
Log::add('Retention policy error (' . $policy->content_type . '): ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
return $results;
}
/**
* Get privacy dashboard summary counts.
*/
public function getDashboardSummary(): object
{
$db = $this->getDatabase();
$summary = (object) [
'pending_requests' => 0,
'total_requests' => 0,
'consent_entries' => 0,
'policies_active' => 0,
];
try
{
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests WHERE status = ' . $db->quote('pending'));
$summary->pending_requests = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests');
$summary->total_requests = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_consent_log');
$summary->consent_entries = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_retention_policies WHERE enabled = 1');
$summary->policies_active = (int) $db->loadResult();
}
catch (\Throwable $e) {}
return $summary;
}
}
@@ -0,0 +1,39 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Privacy;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $requests = [];
protected $policies = [];
protected $summary;
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
$this->requests = $model->getDataRequests($filterStatus);
$this->policies = $model->getRetentionPolicies();
$this->summary = $model->getDashboardSummary();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title('Privacy Guard', 'lock');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -0,0 +1,184 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$requests = $this->requests;
$policies = $this->policies;
$summary = $this->summary;
$token = Session::getFormToken();
$statusBadge = [
'pending' => 'bg-warning text-dark',
'processing' => 'bg-info',
'completed' => 'bg-success',
'denied' => 'bg-secondary',
];
$typeBadge = [
'export' => 'bg-primary',
'delete' => 'bg-danger',
'anonymize' => 'bg-warning text-dark',
];
?>
<div id="mokowaas-privacy">
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3 <?php echo $summary->pending_requests > 0 ? 'text-warning' : 'text-success'; ?>"><?php echo $summary->pending_requests; ?></span>
<small class="text-muted">Pending Requests</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3"><?php echo $summary->total_requests; ?></span>
<small class="text-muted">Total Requests</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3"><?php echo $summary->consent_entries; ?></span>
<small class="text-muted">Consent Entries</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3"><?php echo $summary->policies_active; ?></span>
<small class="text-muted">Active Policies</small>
</div>
</div>
</div>
<div class="row">
<!-- Data Requests -->
<div class="col-12 col-xl-8">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="icon-user-shield"></span> Data Subject Requests</strong>
<form method="get" class="d-inline">
<input type="hidden" name="option" value="com_mokowaas">
<input type="hidden" name="view" value="privacy">
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All</option>
<?php foreach (['pending','processing','completed','denied'] as $s): ?>
<option value="<?php echo $s; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $s ? 'selected' : ''; ?>><?php echo ucfirst($s); ?></option>
<?php endforeach; ?>
</select>
</form>
</div>
<?php if (empty($requests)): ?>
<div class="card-body text-center text-muted py-4">No data requests found.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead><tr><th>#</th><th>User</th><th>Type</th><th>Status</th><th>Created</th><th>Processed</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td><?php echo $r->id; ?></td>
<td><?php echo $this->escape($r->user_name ?? ''); ?><br><small class="text-muted"><?php echo $this->escape($r->user_email ?? ''); ?></small></td>
<td><span class="badge <?php echo $typeBadge[$r->type] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->type); ?></span></td>
<td><span class="badge <?php echo $statusBadge[$r->status] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->status); ?></span></td>
<td class="text-nowrap small"><?php echo HTMLHelper::_('date', $r->created, 'M d, Y H:i'); ?></td>
<td class="text-nowrap small"><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?></td>
<td>
<?php if ($r->status === 'pending'): ?>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-success btn-privacy-action" data-id="<?php echo $r->id; ?>" data-action="approve"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.processDataRequest&format=json'); ?>"
data-token="<?php echo $token; ?>">Approve</button>
<button type="button" class="btn btn-outline-danger btn-privacy-action" data-id="<?php echo $r->id; ?>" data-action="deny"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.processDataRequest&format=json'); ?>"
data-token="<?php echo $token; ?>">Deny</button>
</div>
<?php elseif ($r->status === 'completed' && $r->type === 'export'): ?>
<button type="button" class="btn btn-sm btn-outline-primary btn-export-download" data-user="<?php echo $r->user_id; ?>"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.exportUserData&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-download"></span> Download
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<!-- Retention Policies -->
<div class="col-12 col-xl-4">
<div class="card mb-4">
<div class="card-header"><strong><span class="icon-clock"></span> Retention Policies</strong></div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Type</th><th>Days</th><th>Action</th><th>Active</th></tr></thead>
<tbody>
<?php foreach ($policies as $p): ?>
<tr>
<td class="small"><?php echo $this->escape($p->content_type); ?></td>
<td><?php echo $p->retention_days; ?></td>
<td><span class="badge bg-secondary"><?php echo $p->action; ?></span></td>
<td><?php echo (int) $p->enabled ? '<span class="text-success">Yes</span>' : '<span class="text-muted">No</span>'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Process request buttons
document.querySelectorAll('.btn-privacy-action').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
var action = el.dataset.action;
if (!confirm(action === 'approve' ? 'Approve and process this data request?' : 'Deny this request?')) return;
el.disabled = true;
var fd = new FormData();
fd.append('request_id', el.dataset.id);
fd.append('action', action);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
});
});
});
// Export download
document.querySelectorAll('.btn-export-download').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
var fd = new FormData();
fd.append('user_id', el.dataset.user);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success && d.data) {
var blob = new Blob([JSON.stringify(d.data, null, 2)], {type:'application/json'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'user-data-export-' + el.dataset.user + '.json';
a.click();
} else {
Joomla.renderMessages({error:[d.message || 'Export failed']});
}
});
});
});
});
</script>
+1
View File
@@ -32,6 +32,7 @@
<menu link="option=com_mokowaas&amp;view=extensions" img="class:puzzle-piece">COM_MOKOWAAS_MENU_EXTENSIONS</menu>
<menu link="option=com_mokowaas&amp;view=tickets" img="class:headphones">COM_MOKOWAAS_MENU_TICKETS</menu>
<menu link="option=com_mokowaas&amp;view=htaccess" img="class:file-code">COM_MOKOWAAS_MENU_HTACCESS</menu>
<menu link="option=com_mokowaas&amp;view=privacy" img="class:lock">COM_MOKOWAAS_MENU_PRIVACY</menu>
<menu link="option=com_plugins&amp;filter[folder]=system&amp;filter[search]=mokowaas" img="class:power-off">COM_MOKOWAAS_MENU_PLUGINS</menu>
<menu link="option=com_installer&amp;view=update" img="class:refresh">COM_MOKOWAAS_MENU_UPDATES</menu>
<menu link="option=com_checkin" img="class:check-square">COM_MOKOWAAS_MENU_CHECKIN</menu>
@@ -163,6 +163,26 @@ class DisplayController extends BaseController
}
}
/**
* Submit a data privacy request from frontend.
*/
public function submitDataRequest()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
}
$type = Factory::getApplication()->getInput()->getString('type', '');
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$this->jsonResponse($model->createRequest($user->id, $type, 'Submitted via self-service portal'));
}
/**
* Check if user is support staff (can manage tickets beyond their own).
*/
@@ -0,0 +1,68 @@
<?php
namespace Moko\Component\MokoWaaS\Site\View\Privacy;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
class HtmlView extends BaseHtmlView
{
protected $requests = [];
protected $consent = [];
public function display($tpl = null)
{
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
Factory::getApplication()->redirect(Route::_(
'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=privacy'),
false
));
return;
}
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
// Get user's data requests
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_data_requests'))
->where($db->quoteName('user_id') . ' = ' . (int) $user->id)
->order($db->quoteName('created') . ' DESC');
try
{
$db->setQuery($query);
$this->requests = $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
$this->requests = [];
}
// Get consent history
try
{
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . (int) $user->id)
->order($db->quoteName('created') . ' DESC')
->setLimit(20)
);
$this->consent = $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
$this->consent = [];
}
parent::display($tpl);
}
}
@@ -0,0 +1,114 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$user = Factory::getApplication()->getIdentity();
$requests = $this->requests;
$consent = $this->consent;
$token = Session::getFormToken();
$statusLabel = ['pending' => 'Pending', 'processing' => 'Processing', 'completed' => 'Completed', 'denied' => 'Denied'];
$statusClass = ['pending' => 'warning', 'processing' => 'info', 'completed' => 'success', 'denied' => 'secondary'];
?>
<div class="mokowaas-portal">
<h2>My Privacy &amp; Data</h2>
<p class="text-muted">Manage your personal data, download your information, or request account deletion.</p>
<!-- Action buttons -->
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<button type="button" class="btn btn-primary w-100 py-3 btn-data-request" data-type="export">
<span class="icon-download d-block mb-1" style="font-size:1.5rem"></span>
Download My Data
</button>
</div>
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-warning w-100 py-3 btn-data-request" data-type="anonymize">
<span class="icon-user-shield d-block mb-1" style="font-size:1.5rem"></span>
Anonymize My Account
</button>
</div>
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-danger w-100 py-3 btn-data-request" data-type="delete">
<span class="icon-trash d-block mb-1" style="font-size:1.5rem"></span>
Delete My Account
</button>
</div>
</div>
<!-- My requests -->
<?php if (!empty($requests)): ?>
<div class="card mb-4">
<div class="card-header"><strong>My Data Requests</strong></div>
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead><tr><th>Type</th><th>Status</th><th>Submitted</th><th>Processed</th></tr></thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td><?php echo ucfirst($r->type); ?></td>
<td><span class="badge bg-<?php echo $statusClass[$r->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$r->status] ?? $r->status; ?></span></td>
<td><?php echo HTMLHelper::_('date', $r->created, 'M d, Y H:i'); ?></td>
<td><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Consent history -->
<?php if (!empty($consent)): ?>
<div class="card mb-4">
<div class="card-header"><strong>Consent History</strong></div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Category</th><th>Action</th><th>Date</th></tr></thead>
<tbody>
<?php foreach ($consent as $c): ?>
<tr>
<td><?php echo htmlspecialchars(ucwords(str_replace('_', ' ', $c->category))); ?></td>
<td><span class="badge bg-<?php echo $c->action === 'granted' ? 'success' : 'secondary'; ?>"><?php echo ucfirst($c->action); ?></span></td>
<td><?php echo HTMLHelper::_('date', $c->created, 'M d, Y H:i'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<script>
document.querySelectorAll('.btn-data-request').forEach(function(btn) {
btn.addEventListener('click', function() {
var type = this.dataset.type;
var messages = {
'export': 'Request a download of all your personal data?',
'anonymize': 'Request your account to be anonymized? Your name, email, and personal details will be replaced. This cannot be undone.',
'delete': 'Request permanent deletion of your account and all data? This cannot be undone.'
};
if (!confirm(messages[type] || 'Submit this request?')) return;
this.disabled = true;
var fd = new FormData();
fd.append('type', type);
fd.append('<?php echo $token; ?>', '1');
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.submitDataRequest&format=json"); ?>', {
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { alert(d.message); location.reload(); }
else { alert(d.message || 'Failed.'); }
})
.catch(function() { alert('Network error.'); })
.finally(function() { btn.disabled = false; });
});
});
</script>