# Product Module Client Management

https://dev.wisecp.com/es/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.

- **has_client_management()**: The tab gate.
- **client_overview_data()**: Gauges and account rows for the overview tab.
- **client_quick_actions()**: Shortcut buttons on the overview.
- **client_callable_methods**: The allowlist of customer-runnable names.
- **$client_area**: True only in the customer view of the dashboard, false in the admin view.

## Prerequisites

- A working product module: [Writing a Product Module](https://dev.wisecp.com/en/writing-a-product-module).
- A service of type `special` or `software`, `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 |

```bash
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.

1. Create `pages/dashboard.php`. Inside it, `$module` is the live instance.
2. For an admin-only dashboard, override the gate. The admin service page still shows it.
3. 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.

1. `gauges`: one entry per limited resource, zero or below meaning unlimited.
2. `resources`: optional counters — the used and total shape, or a single `value` that is not a ratio.
3. `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.

1. Declare `public array $client_callable_methods = ['reset_usage'];`, without the prefix.
2. Write `public function handle_reset_usage(): array` — no arguments; read from the request and the service.
3. 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

```php
// 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

```php
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'],
    ],
];
```

- **gauges: total**: Zero or below is unlimited: the gauge moves to the specification list with the infinity sign.
- **gauges: no percentage**: Send raw numbers. The theme computes the ratio and the colour tier; a pre-computed percentage is ignored.
- **resources: total of zero**: Zero is a real limit here, unlike a gauge. Only a negative total is unlimited.
- **account: type**: `text`, `link`, `badge` or `password`. `copyable` adds a copy button; a link row takes the full width.
- **account: badge_color**: `success`, `warning`, `danger` or the default. The icon comes from the colour.
- **account: the password row**: Injected after the row keyed `username`, or appended when there is no such row.
- **every value is shown as given**: Format dates and numbers first: a raw provider timestamp reaches the customer as one.

### client_quick_actions() Shape

```php
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.

```php
// 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"));
```

- **ownership, type and module**: It must belong to the signed-in account, be hosting, server, special or software, and name a module other than `none`. A service marked with Restrict Service Details is also not found.
- **the allowlist is not optional**: An undeclared handler is refused even when it exists, and silently, so it reads like a broken button. That is what keeps `handle_create` unreachable.
- **token and live service**: The owner lookup already demands `active`, so a suspended service is refused for reads too. Mutations also need a valid token.
- **gate:service.client_tool**: Runs immediately before your handler. A listener returning a non empty string vetoes the call with that message.
- **action:service.client_tool_ran**: Fires afterwards with the service, the requested method, the resolved method name and the result.
- **filter:client.service_management_content**: Filters the finished dashboard HTML by reference, before the inline scripts are split out.

## Example

The module half and the dashboard half; the second makes the `$client_area` decision.

```php
// 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
<?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

> **One unguarded element lookup kills the whole panel**
> 
> 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.

> **A shortcut is a shortcut, not the home of an action**
> 
> Put the action on the dashboard and let the shortcut point at it.

> **Empty page under the scheduler**
> 
> 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

- [Writing a Product Module](https://dev.wisecp.com/en/writing-a-product-module)
- [The Client Area Bridge](https://dev.wisecp.com/en/the-client-area-bridge)
- [Server Module Tools](https://dev.wisecp.com/en/server-module-tools)
- [The Client Area](https://dev.wisecp.com/en/the-client-area)
- [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module)
- [Security Practices](https://dev.wisecp.com/en/security-practices)
