Product Module Client Management
Five opt-in members decide what a customer sees and may do on a product service. They are the tab, the gauges, the shortcuts, the actions and the view flag.
Overview
A product module exposes nothing to the customer by default. The five members below are opt in and type agnostic, so filling them needs no core change.
The overview tab shows usage rings and account rows from client_overview_data(); the management tab shows your own dashboard page. Both can carry gauges, so the module has to know which one it is filling.
Prerequisites
- A working product module: Writing a Product Module.
- A service of type
specialorsoftware,active, with a module bound. Every other state, suspended included, is refused first. - Test as the customer; the admin view never sets the flag.
Structure
| What you want | What the module does | Default |
|---|---|---|
| No customer screen at all | Nothing | The default |
| A management tab | Ship pages/dashboard.php or define page_dashboard() | No tab |
| No tab, dashboard for admins only | Override has_client_management() to return false | Tab appears |
| Usage gauges on the overview | Fill client_overview_data() | Empty, no gauges |
| Shortcuts on the overview | Fill client_quick_actions() | Empty, no shortcuts |
| A runnable action | Declare it in $client_callable_methods, write handle_{name}() | Refused |
| An action over GET (a download) | Also list it in $client_readonly_methods | Token and POST |
| Hide a dashboard block from customers | Check $client_area around it | Shown to both |
Management tab clicked
└─ GET use_module_method-less request -> ClientServices::get_management_details
├─ owner + type + active-state check
├─ client_module_instance() -> $client_area = true, $area_link set
├─ ProductModule::service_management_page() -> get_page('dashboard')
└─ inline script tags split out, evaluated separately by the theme bridge
Overview tab
└─ the services controller, for special and software services
├─ client_overview_data() -> usage rings + account rows
└─ client_quick_actions() -> shortcut buttons
A shortcut or a dashboard button
└─ POST use_module_method -> allowlist -> handle_{name}()
Walkthrough
Open the Management Tab
Shipping a dashboard page is the opt in: the gate looks for the file.
- Create
pages/dashboard.php. Inside it,$moduleis the live instance. - For an admin-only dashboard, override the gate. The admin service page still shows it.
- The theme bridge strips inline scripts out of the HTML and evaluates them separately.
Fill the Overview
client_overview_data() returns three lists. Return an empty array while nothing is provisioned: the overview then shows no gauges.
gauges: one entry per limited resource, zero or below meaning unlimited.resources: optional counters — the used and total shape, or a singlevaluethat is not a ratio.account: identity rows. The service password is injected for you.
Expose an Action
Two things are required, neither alone: the name in the allowlist and a handle_ prefixed method.
- Declare
public array $client_callable_methods = ['reset_usage'];, without the prefix. - Write
public function handle_reset_usage(): array— no arguments; read from the request and the service. - Build the API client inside the handler: a customer call has nothing set up for it.
Avoid Showing Everything Twice
The same file also serves the admin service page, which has no overview tab. Hide duplicated blocks behind $client_area rather than deleting them.
Reference
The Five Members
// Declared on ProductModule; override what you need.
public bool $client_area = false; // set to true only by the client renderer
public array $client_callable_methods = []; // names WITHOUT the handle_ prefix
public array $client_readonly_methods = []; // a subset of the above, GET-safe
public function has_client_management(): bool;
public function client_overview_data(): array;
public function client_quick_actions(int $limit = 8): array;
// Your handler for a declared callable. No parameters; returns the JSON payload.
public function handle_reset_usage(): array;
client_overview_data() Shape
return [
'gauges' => [
[
'key' => 'storage',
'label' => $this->lang['storage-usage'],
'icon' => 'bi bi-hdd',
'used' => 4.5,
'total' => 20, // 0 or below means unlimited; pass -1 for clarity
'unit' => 'GB',
],
],
'resources' => [
// Ratio form: same keys as a gauge.
['key' => 'seats', 'label' => 'Seats', 'icon' => 'bi bi-people', 'used' => 3, 'total' => 10, 'unit' => ''],
// Value form: a single reading with no limit.
['key' => 'region', 'label' => 'Region', 'icon' => 'bi bi-globe', 'value' => 'eu-west'],
],
'account' => [
['key' => 'account_id', 'label' => 'Account ID', 'type' => 'text', 'copyable' => true, 'value' => 'ac_1042'],
['key' => 'username', 'label' => 'Username', 'type' => 'text', 'copyable' => true, 'value' => 'acme'],
['key' => 'status', 'label' => 'Status', 'type' => 'badge', 'badge_color' => 'success', 'value' => 'Active'],
['key' => 'console', 'label' => 'Console', 'type' => 'link', 'value' => 'https://panel.example.com'],
],
];
text, link, badge or password. copyable adds a copy button; a link row takes the full width.
success, warning, danger or the default. The icon comes from the colour.
username, or appended when there is no such row.
client_quick_actions() Shape
public function client_quick_actions(int $limit = 8): array
{
$actions = [
// action => true: runs the handler in place, through the callable allowlist.
['key' => 'reset_usage', 'label' => $this->lang['action-reset-usage'],
'icon' => 'bi bi-arrow-counterclockwise', 'action' => true, 'method' => 'reset_usage'],
// action => false: navigates to a page inside the management tab instead.
['key' => 'backups', 'label' => $this->lang['action-backups'],
'icon' => 'bi bi-archive', 'action' => false, 'method' => 'backups'],
];
// Honour the limit: the caller decides how many fit.
return array_slice($actions, 0, $limit);
}
Before Your Handler Runs
Most "my action does nothing" reports are one of these refusals.
// ClientServices::use_module_method, reduced to the decisions.
$service = $this->owned_managed_service($uid); // owner, type, module and active-state check
$method = (string) Filter::init("REQUEST/method", "route");
$module = $this->client_module_instance($service); // sets $client_area and $area_link
$handleMethod = 'handle_' . str_replace('-', '_', $method);
$isTool = in_array($method, ['tool_action', 'tool_table', 'sso_panel_login'], true);
// BOTH conditions: declared in the allowlist AND the handler actually exists.
$isClientCallable = !$isTool
&& in_array($method, $module->client_callable_methods ?? [], true)
&& method_exists($module, $handleMethod);
if (!$isTool && !$isClientCallable)
throw new \Exception(Language::gc("website/services/err-invalid"));
// Read-only callables skip the token and the active-service requirement.
$isReadonly = $isClientCallable && in_array($method, $module->client_readonly_methods ?? [], true);
$mutates = ($method === 'tool_action' || $isClientCallable) && !$isReadonly;
if ($mutates && !\Validation::verify_csrf_token((string) Filter::init("POST/token", "hclear"), "services"))
throw new \Exception(Language::g("needs/csrf-failed"));
if ($mutates && ($service['status'] ?? '') !== 'active')
throw new \Exception(Language::gc("website/services/err-invalid"));
none. A service marked with Restrict Service Details is also not found.
handle_create unreachable.
active, so a suspended service is refused for reads too. Mutations also need a valid token.
Example
The module half and the dashboard half; the second makes the $client_area decision.
// Declared without the handle_ prefix. Anything absent here is refused.
public array $client_callable_methods = ['reset_usage', 'download_report'];
// Served over a GET link, so exempt from the token and the POST requirement.
public array $client_readonly_methods = ['download_report'];
public function client_overview_data(): array
{
$remote = $this->fetchRemoteStatus();
if (!$remote) return []; // nothing provisioned yet: no gauges
$gauges = [];
if (isset($remote['storage_limit']))
$gauges[] = [
'key' => 'storage',
'label' => $this->lang['storage-usage'],
'icon' => 'bi bi-hdd',
'used' => (float) ($remote['used_storage'] ?? 0),
'total' => (float) $remote['storage_limit'] > 0 ? (float) $remote['storage_limit'] : -1,
'unit' => 'GB',
];
$account = [];
$username = (string) ($this->options['login']['username'] ?? '');
// The controller injects the password row right after this one.
if ($username !== '')
$account[] = ['key' => 'username', 'label' => $this->lang['username'], 'type' => 'text', 'copyable' => true, 'value' => $username];
$status = (string) ($remote['status'] ?? '');
if ($status !== '')
$account[] = [
'key' => 'status',
'label' => $this->lang['account-status'],
'type' => 'badge',
'badge_color' => $status === 'active' ? 'success' : 'secondary',
'value' => $this->lang['status-' . $status] ?? ucfirst($status),
];
if (!$gauges && !$account) return [];
return ['gauges' => $gauges, 'resources' => [], 'account' => $account];
}
public function handle_reset_usage(): array
{
// Client-triggered: nothing has prepared the API client for you.
$this->initApi();
$accountId = $this->options['config']['id'] ?? '';
if (!$accountId) throw new Exception($this->lang['err-not-provisioned']);
$this->api->call('accounts/' . $accountId . '/usage/reset', [], 'POST');
return ['status' => 'successful', 'message' => $this->lang['usage-reset-ok']];
}
<?php
/** @var ProductModule $module */
// Admin has no Overview tab, so it must keep these blocks. The customer already
// sees the same numbers there, so hide them here rather than deleting them.
if (!($module->client_area ?? false)):
?>
<div class="row">
<!-- gauge widgets and the account card -->
</div>
<?php endif; ?>
<!-- Actions stay in both views: this is the management surface. -->
<button type="button" class="btn btn-primary" id="acmeResetUsage">Reset Usage</button>
<script>
(function () {
// Null-guard every lookup: a block hidden by $client_area leaves its ids missing,
// and an uncaught error here takes the whole panel down, not just this button.
var btn = document.getElementById('acmeResetUsage');
if (!btn) return;
btn.addEventListener('click', function () {
run_module_method('reset_usage');
});
})();
</script>
Pitfalls
A block hidden by $client_area takes its element ids with it, and inline script that calls addEventListener on the missing node throws. The script bridge throws with it, so the customer gets a generic "could not load" message. Guard every lookup.
Put the action on the dashboard and let the shortcut point at it.
Template output is skipped in the scheduled-task context. A command line probe reports a zero length page while the real request returns the full panel. Check over real HTTP or impersonation.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.