From a6036cb082fb8aab5a8d4ef3a2c94cffbecddec5 Mon Sep 17 00:00:00 2001 From: Travis Garrison Date: Wed, 22 Jul 2026 17:02:42 +0400 Subject: [PATCH] feat(plugins): add registerModule helper and replace asset injection with explicit CSS links - Add BasePlugin::registerModule() to register permission-system modules, auto-grant to admin (person_id=1), idempotent via INSERT IGNORE - Replace inject:css/js placeholders in header.php with explicit versioned asset links for reliable loading outside build pipeline - Update home.php module icons to use pluginContent() hook with SVG fallback, enabling plugins to override module icons Signed-off-by: Travis Garrison --- app/Libraries/Plugins/BasePlugin.php | 78 ++++++++++++ app/Plugins/README.md | 180 +++++++++++++++++++++++++++ app/Views/home/home.php | 8 +- app/Views/home/office.php | 8 +- app/Views/partial/header.php | 6 +- 5 files changed, 277 insertions(+), 3 deletions(-) diff --git a/app/Libraries/Plugins/BasePlugin.php b/app/Libraries/Plugins/BasePlugin.php index fc1dc518c..3249ff591 100644 --- a/app/Libraries/Plugins/BasePlugin.php +++ b/app/Libraries/Plugins/BasePlugin.php @@ -81,4 +81,82 @@ abstract class BasePlugin implements PluginInterface $namespace = substr(get_class($this), 0, strrpos(get_class($this), '\\')); return view($namespace . '\\Views\\' . $viewName, $data); } + + /** + * Register a module in the permission system and auto-grant it to admin (person_id=1). + * Call from install(). Idempotent — safe to call multiple times. + * + * Convention: prefix module_id with plugin_id (e.g. 'mailchimp_dashboard'). + * Language keys resolved from plugin's Language/{locale}/Module.php file. + */ + protected function registerModule( + string $module_id, + int $sort = 500, + string $admin_menu_group = 'office' + ): bool { + $db = \Config\Database::connect(); + $db->table('modules')->ignore(true)->insert([ + 'module_id' => $module_id, + 'name_lang_key' => 'module_' . $module_id, + 'desc_lang_key' => 'module_' . $module_id . '_desc', + 'sort' => $sort, + ]); + $db->table('permissions')->ignore(true)->insert([ + 'permission_id' => $module_id, + 'module_id' => $module_id, + ]); + $db->table('grants')->ignore(true)->insert([ + 'permission_id' => $module_id, + 'person_id' => 1, + 'menu_group' => $admin_menu_group, + ]); + return true; + } + + /** + * Remove a module and all its permissions from the permission system. + * Grants cascade automatically via FK. Call from uninstall(). + * + * Note: ospos_permissions has no FK cascade from ospos_modules, so + * permissions must be deleted before the module row. + */ + protected function unregisterModule(string $module_id): bool + { + $db = \Config\Database::connect(); + $db->table('permissions')->where('module_id', $module_id)->delete(); + $db->table('modules')->where('module_id', $module_id)->delete(); + return true; + } + + /** + * Register a sub-permission under an existing module and auto-grant to admin. + * Sub-permissions use menu_group '--' per core convention. + * Call from install() after registerModule(). + */ + protected function registerSubPermission(string $permission_id, string $module_id): bool + { + $db = \Config\Database::connect(); + $db->table('permissions')->ignore(true)->insert([ + 'permission_id' => $permission_id, + 'module_id' => $module_id, + ]); + $db->table('grants')->ignore(true)->insert([ + 'permission_id' => $permission_id, + 'person_id' => 1, + 'menu_group' => '--', + ]); + return true; + } + + /** + * Remove a specific sub-permission. Grants cascade automatically via FK. + * unregisterModule() already handles this for all sub-permissions under a module, + * so only call this directly when removing a sub-permission independently. + */ + protected function unregisterSubPermission(string $permission_id): bool + { + \Config\Database::connect()->table('permissions') + ->where('permission_id', $permission_id)->delete(); + return true; + } } diff --git a/app/Plugins/README.md b/app/Plugins/README.md index 3473453dc..b721360ee 100644 --- a/app/Plugins/README.md +++ b/app/Plugins/README.md @@ -813,6 +813,186 @@ log_plugin_message('myplugin', 'debug', 'API call', 'api'); Check logs in `writable/logs/`. +## Toast Notifications (Pop-up Alerts) + +OSPOS uses [Bootstrap Notify](https://github.com/mouse0270/bootstrap-notify) for in-page toast notifications via `$.notify()`. It is loaded globally on every page. + +### From JavaScript (AJAX responses) + +The standard pattern for plugin AJAX calls is to fire a toast in the response callback: + +```javascript +$.ajax({ + type: 'POST', + url: '', + dataType: 'json', + success: function(response) { + $.notify({ message: response.message }, { type: response.success ? 'success' : 'danger' }); + }, + error: function() { + $.notify({ message: 'Something went wrong.' }, { type: 'danger' }); + } +}); +``` + +Available `type` values: `success`, `danger`, `warning`, `info`. + +### From PHP (after a page load or redirect) + +When an event handler runs server-side and needs to surface an error or message on the next rendered page, use CI4 session flash data. Store the message in the event handler: + +```php +public function onSaleCompleted(int $saleId, string $saleType, array $pluginData = []): void +{ + $response = $this->myLibrary->processTransaction($saleId); + + if (!$response->isSuccess()) { + session()->setFlashdata('myplugin_error', $response->getMessage()); + } +} +``` + +Then read it in the injected view partial that is rendered on that same page: + +```php +// In your plugin view partial (Views/my_partial.php) + +``` + +Flash data is consumed on the first read and automatically cleared — no cleanup needed. Use `json_encode()` to safely embed the PHP string into JS without XSS risk. + +## Registering Modules (Admin Menu Entries) + +A **module** is a menu entry in the OSPOS admin sidebar. Registering one gives the plugin its own page in the navigation and wires up the permission system so access can be granted per user. + +### Register in `install()` + +Use `BasePlugin::registerModule()` inside your `install()` method: + +```php +public function install(): bool +{ + // ... create tables, set defaults ... + $this->registerModule('myplugin', 500, 'office'); + return true; +} +``` + +Parameters: + +| Parameter | Type | Description | +|---|---|---| +| `$module_id` | string | Unique identifier — **prefix with plugin ID** (e.g. `myplugin_dashboard`) | +| `$sort` | int | Position in the menu list (lower = higher up). Default: 500 | +| `$admin_menu_group` | string | Menu group: `'office'`, `'reports'`, etc. | + +`registerModule()` is idempotent — safe to call multiple times. It inserts the module and permission rows and auto-grants access to `person_id = 1` (the admin user). + +### Unregister in `uninstall()` + +```php +public function uninstall(): bool +{ + $this->unregisterModule('myplugin'); + // ... drop tables ... + return true; +} +``` + +This removes the module and its permissions. User grants cascade automatically via foreign key. + +### Language Keys + +Views display module names and descriptions by calling: + +```php +lang("Module.{$module->module_id}") // display name +lang("Module.{$module->module_id}_desc") // description +``` + +CI4 splits on the dot: `Module` is the **file name**, the rest is the **key**. This means the strings must live in a file named `Module.php` — they cannot be merged into your plugin's main language file (e.g. `MyPlugin.php`). + +The file must be inside the plugin's own `Language/{locale}/` directory, not in `app/Language/`. PluginManager registers the plugin's PSR-4 namespace at startup; CI4's FileLocator searches all registered namespaces when loading language files, so it finds `app/Plugins/MyPlugin/Language/en/Module.php` automatically. + +Create `Language/en/Module.php` in your plugin directory: + +```php + 'My Plugin', + 'myplugin_desc' => 'Manage My Plugin settings and operations.', +]; +``` + +For other locales, create the same file under `Language/{locale}/` with an empty string for untranslated keys — CI4 falls back to `en` automatically: + +```php + 'Mi Plugin', + 'myplugin_desc' => '', +]; +``` + +### Controller and Route + +The module menu link routes to `/{module_id}`. Create a controller and register the route in `Config/Routes.php`: + +```php +$routes->get('myplugin', '\App\Plugins\MyPlugin\Controllers\MyPluginController::getIndex'); +$routes->get('myplugin/(:any)', '\App\Plugins\MyPlugin\Controllers\MyPluginController::getIndex'); +``` + +The controller should extend `Secure_Controller` so the permission check is enforced automatically: + +```php +class MyPluginController extends \App\Controllers\Secure_Controller +{ + public function __construct() + { + parent::__construct('myplugin'); + } + + public function getIndex(): string + { + return view('App\Plugins\MyPlugin\Views\dashboard'); + } +} +``` + +Passing the `module_id` to `Secure_Controller`'s constructor restricts access to users who have been granted that permission. + +### Sub-permissions + +For finer-grained access control within a module, register sub-permissions after the module: + +```php +public function install(): bool +{ + $this->registerModule('myplugin', 500, 'office'); + $this->registerSubPermission('myplugin_reports', 'myplugin'); + return true; +} +``` + +Check sub-permissions in the controller or view: + +```php +$employee = model(\App\Models\Employee::class); +if ($employee->has_grant('myplugin_reports', session('person_id'))) { + // show reports section +} +``` + ## Distributing Plugins Plugin developers can package their plugins as zip files: diff --git a/app/Views/home/home.php b/app/Views/home/home.php index 84237c90f..75870ea97 100644 --- a/app/Views/home/home.php +++ b/app/Views/home/home.php @@ -15,7 +15,13 @@