From dd20e42cb2d02683ba0d49337a53159defeb0321 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Wed, 3 Jun 2026 11:53:54 -0500 Subject: [PATCH] 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) --- .../admin/language/en-GB/com_mokowaas.sys.ini | 1 + .../com_mokowaas/admin/sql/install.mysql.sql | 48 ++ .../src/Controller/DisplayController.php | 39 ++ .../admin/src/Model/PrivacyModel.php | 590 ++++++++++++++++++ .../admin/src/View/Privacy/HtmlView.php | 39 ++ .../admin/tmpl/privacy/default.php | 184 ++++++ src/packages/com_mokowaas/mokowaas.xml | 1 + .../site/src/Controller/DisplayController.php | 20 + .../site/src/View/Privacy/HtmlView.php | 68 ++ .../site/tmpl/privacy/default.php | 114 ++++ 10 files changed, 1104 insertions(+) create mode 100644 src/packages/com_mokowaas/admin/src/Model/PrivacyModel.php create mode 100644 src/packages/com_mokowaas/admin/src/View/Privacy/HtmlView.php create mode 100644 src/packages/com_mokowaas/admin/tmpl/privacy/default.php create mode 100644 src/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php create mode 100644 src/packages/com_mokowaas/site/tmpl/privacy/default.php diff --git a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini index bd380654..e5da7db4 100644 --- a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini +++ b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini @@ -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" diff --git a/src/packages/com_mokowaas/admin/sql/install.mysql.sql b/src/packages/com_mokowaas/admin/sql/install.mysql.sql index 208a25bd..0bd447a0 100644 --- a/src/packages/com_mokowaas/admin/sql/install.mysql.sql +++ b/src/packages/com_mokowaas/admin/sql/install.mysql.sql @@ -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)'); diff --git a/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php b/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php index 01ca5a98..648340a8 100644 --- a/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php +++ b/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php @@ -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 // ================================================================== diff --git a/src/packages/com_mokowaas/admin/src/Model/PrivacyModel.php b/src/packages/com_mokowaas/admin/src/Model/PrivacyModel.php new file mode 100644 index 00000000..9bb53564 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Model/PrivacyModel.php @@ -0,0 +1,590 @@ +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; + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Privacy/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Privacy/HtmlView.php new file mode 100644 index 00000000..b4d7e52d --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Privacy/HtmlView.php @@ -0,0 +1,39 @@ +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'); + } +} diff --git a/src/packages/com_mokowaas/admin/tmpl/privacy/default.php b/src/packages/com_mokowaas/admin/tmpl/privacy/default.php new file mode 100644 index 00000000..0b6f6f72 --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/privacy/default.php @@ -0,0 +1,184 @@ +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', +]; +?> + +
+ +
+
+
+ pending_requests; ?> + Pending Requests +
+
+
+
+ total_requests; ?> + Total Requests +
+
+
+
+ consent_entries; ?> + Consent Entries +
+
+
+
+ policies_active; ?> + Active Policies +
+
+
+ +
+ +
+
+
+ Data Subject Requests +
+ + + +
+
+ +
No data requests found.
+ +
+ + + + + + + + + + + + + + + +
#UserTypeStatusCreatedProcessedActions
id; ?>escape($r->user_name ?? ''); ?>
escape($r->user_email ?? ''); ?>
type); ?>status); ?>created, 'M d, Y H:i'); ?>processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?> + status === 'pending'): ?> +
+ + +
+ status === 'completed' && $r->type === 'export'): ?> + + +
+
+ +
+
+ + +
+
+
Retention Policies
+
+ + + + + + + + + + + + +
TypeDaysActionActive
escape($p->content_type); ?>retention_days; ?>action; ?>enabled ? 'Yes' : 'No'; ?>
+
+
+
+
+
+ + diff --git a/src/packages/com_mokowaas/mokowaas.xml b/src/packages/com_mokowaas/mokowaas.xml index 2191ecff..dea03743 100644 --- a/src/packages/com_mokowaas/mokowaas.xml +++ b/src/packages/com_mokowaas/mokowaas.xml @@ -32,6 +32,7 @@ COM_MOKOWAAS_MENU_EXTENSIONS COM_MOKOWAAS_MENU_TICKETS COM_MOKOWAAS_MENU_HTACCESS + COM_MOKOWAAS_MENU_PRIVACY COM_MOKOWAAS_MENU_PLUGINS COM_MOKOWAAS_MENU_UPDATES COM_MOKOWAAS_MENU_CHECKIN diff --git a/src/packages/com_mokowaas/site/src/Controller/DisplayController.php b/src/packages/com_mokowaas/site/src/Controller/DisplayController.php index 7d820b9f..4cecaf97 100644 --- a/src/packages/com_mokowaas/site/src/Controller/DisplayController.php +++ b/src/packages/com_mokowaas/site/src/Controller/DisplayController.php @@ -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). */ diff --git a/src/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php b/src/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php new file mode 100644 index 00000000..a6b70082 --- /dev/null +++ b/src/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php @@ -0,0 +1,68 @@ +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); + } +} diff --git a/src/packages/com_mokowaas/site/tmpl/privacy/default.php b/src/packages/com_mokowaas/site/tmpl/privacy/default.php new file mode 100644 index 00000000..f26b4e6a --- /dev/null +++ b/src/packages/com_mokowaas/site/tmpl/privacy/default.php @@ -0,0 +1,114 @@ +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']; +?> + +
+

My Privacy & Data

+

Manage your personal data, download your information, or request account deletion.

+ + +
+
+ +
+
+ +
+
+ +
+
+ + + +
+
My Data Requests
+
+ + + + + + + + + + + + +
TypeStatusSubmittedProcessed
type); ?>status] ?? $r->status; ?>created, 'M d, Y H:i'); ?>processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?>
+
+
+ + + + +
+
Consent History
+
+ + + + + + + + + + + +
CategoryActionDate
category))); ?>action); ?>created, 'M d, Y H:i'); ?>
+
+
+ +
+ +