feat: admin settings page UI/UX pass with WordPress-native patterns

- Header: h1 + logo in a wrapping flex row, hr.wp-header-end before the tabs,
  aria-current on the active tab; own CSS/JS enqueued only on this screen.
- Verbindung: ordered "So verbindest du Claude" steps, copy button (core
  clipboard.js + wp.a11y.speak), Site-Health-style status list, German details.
- Zugriff: roles as form-table fieldset, connections with site date format,
  empty-state row, confirm on revoke; ConnectionUsage records last use via
  WP_Application_Passwords::record_application_password_usage (once a day).
- Tools: plain-language legend, counter as inline notice, groups as core
  postboxes (postbox.js toggles, per-user closed state, summary when closed),
  fixed column widths with new "Art" column, submit above and below.
- Protokoll: site-timezone times with UTC tooltip, result icons, filters with
  labels in the list table nav, retention link to Limits.
- Limits: short labels with units and descriptions; snapshot count hidden but
  preserved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 16:50:58 +02:00
co-authored by Claude Opus 5.5
parent 5965e32acb
commit 843f45aeec
22 changed files with 1535 additions and 242 deletions
+67 -30
View File
@@ -89,16 +89,17 @@ final class AccessTab implements AdminTab
public function render(): void
{
$allowed = $this->access->allowedRoles();
$legend = __('Rollen mit Zugriff', 'wp-lit-mcp');
echo '<h2>' . esc_html__('Freigegebene Rollen', 'wp-lit-mcp') . '</h2>';
echo '<p>' . esc_html__(
'Nur Benutzer dieser Rollen können Tools nutzen; zusätzlich gelten ihre normalen WordPress-Rechte.',
'wp-lit-mcp'
) . '</p>';
echo '<form method="post" action="options.php">';
settings_fields(self::GROUP);
// Makes an all-unchecked submission arrive as [''] instead of dropping the field.
printf('<input type="hidden" name="%s[]" value="">', esc_attr(Access::OPTION));
printf(
'<table class="form-table" role="presentation"><tbody><tr><th scope="row">%1$s</th><td>'
. '<fieldset><legend class="screen-reader-text"><span>%1$s</span></legend>',
esc_html($legend)
);
foreach (wp_roles()->get_names() as $role => $name) {
printf(
'<label><input type="checkbox" name="%s[]" value="%s"%s> %s</label><br>',
@@ -108,6 +109,10 @@ final class AccessTab implements AdminTab
esc_html(translate_user_role((string) $name))
);
}
echo '</fieldset><p class="description">'
. esc_html__('Nur Benutzer dieser Rollen können Claude verbinden.', 'wp-lit-mcp') . ' '
. esc_html__('Innerhalb der Tools gelten zusätzlich ihre normalen WordPress-Rechte.', 'wp-lit-mcp')
. '</p></td></tr></tbody></table>';
submit_button();
echo '</form>';
@@ -160,31 +165,42 @@ final class AccessTab implements AdminTab
private function renderConnections(): void
{
echo '<h2>' . esc_html__('Aktive Verbindungen', 'wp-lit-mcp') . '</h2>';
echo '<h2>' . esc_html__('Verbindungen', 'wp-lit-mcp') . '</h2>';
$connections = $this->connections->all();
printf(
'<table class="widefat striped lit-mcp-connections"><thead><tr>'
. '<th scope="col" class="column-user">%s</th><th scope="col" class="column-client">%s</th>'
. '<th scope="col" class="column-created">%s</th><th scope="col" class="column-last-used">%s</th>'
. '<th scope="col" class="column-actions"><span class="screen-reader-text">%s</span></th>'
. '</tr></thead><tbody>',
esc_html__('Benutzer', 'wp-lit-mcp'),
esc_html__('Client', 'wp-lit-mcp'),
esc_html__('Verbunden am', 'wp-lit-mcp'),
esc_html__('Zuletzt genutzt', 'wp-lit-mcp'),
esc_html__('Aktionen', 'wp-lit-mcp')
);
if ($connections === []) {
echo '<p>' . esc_html__('Keine aktiven Verbindungen.', 'wp-lit-mcp') . '</p>';
return;
printf(
'<tr class="no-items"><td class="colspanchange" colspan="5">%s %s <a href="%s">%s</a></td></tr>',
esc_html__('Noch keine Verbindung.', 'wp-lit-mcp'),
esc_html__('So verbindest du Claude:', 'wp-lit-mcp'),
esc_url(SettingsPage::url('verbindung')),
esc_html__('Tab „Verbindung“', 'wp-lit-mcp')
);
}
echo '<table class="widefat striped"><thead><tr>';
$headings = [
__('Benutzer', 'wp-lit-mcp'),
__('Client', 'wp-lit-mcp'),
__('Erstellt', 'wp-lit-mcp'),
__('Zuletzt genutzt', 'wp-lit-mcp'),
'',
];
foreach ($headings as $heading) {
echo '<th>' . esc_html($heading) . '</th>';
}
echo '</tr></thead><tbody>';
foreach ($connections as $connection) {
$this->renderConnectionRow($connection);
}
echo '</tbody></table>';
if ($connections !== []) {
echo '<p class="description">' . esc_html__(
'„Zuletzt genutzt“ wird wie bei Anwendungspasswörtern höchstens einmal am Tag aktualisiert.',
'wp-lit-mcp'
) . '</p>';
}
}
/**
@@ -193,21 +209,42 @@ final class AccessTab implements AdminTab
*/
private function renderConnectionRow(array $connection): void
{
$dateFormat = (string) get_option('date_format', 'd.m.Y');
$timeFormat = (string) get_option('time_format', 'H:i');
echo '<tr>';
echo '<td>' . esc_html($connection['user_login']) . '</td>';
printf('<td>%s<br><code>%s</code></td>', esc_html($connection['name']), esc_html($connection['app_id']));
echo '<td>' . esc_html((string) wp_date('Y-m-d H:i', $connection['created'])) . '</td>';
echo '<td class="column-user">' . esc_html($connection['user_login']) . '</td>';
printf(
'<td class="column-client"><strong>%s</strong><br><span class="lit-mcp-muted">%s</span></td>',
esc_html($connection['name']),
esc_html($connection['app_id'])
);
printf(
'<td class="column-created">%s</td>',
esc_html((string) wp_date($dateFormat . ' ' . $timeFormat, $connection['created']))
);
$lastUsed = $connection['last_used'] === null
? '–'
: (string) wp_date('Y-m-d H:i', $connection['last_used']);
echo '<td>' . esc_html($lastUsed) . '</td>';
echo '<td>';
: (string) wp_date($dateFormat, $connection['last_used']);
echo '<td class="column-last-used">' . esc_html($lastUsed) . '</td>';
echo '<td class="column-actions">';
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::REVOKE_ACTION));
printf('<input type="hidden" name="user_id" value="%s">', esc_attr((string) $connection['user_id']));
printf('<input type="hidden" name="uuid" value="%s">', esc_attr($connection['uuid']));
wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']);
submit_button(__('Widerrufen', 'wp-lit-mcp'), 'delete small', 'submit', false);
$confirm = esc_js(sprintf(
/* translators: %s: client name */
__('Verbindung „%s“ widerrufen? Claude muss sich danach neu anmelden.', 'wp-lit-mcp'),
$connection['name']
));
submit_button(
__('Widerrufen', 'wp-lit-mcp'),
'delete small',
'submit',
false,
['onclick' => "return window.confirm('" . $confirm . "');"]
);
echo '</form></td></tr>';
}
}
+11 -6
View File
@@ -93,7 +93,7 @@ class ConnectionStatus
$code = (int) wp_remote_retrieve_response_code($response);
$challenge = (string) wp_remote_retrieve_header($response, 'www-authenticate');
$result = $code === 401 && str_starts_with($challenge, 'Bearer')
? ['ok' => true, 'message' => 'Ohne Token: 401 mit WWW-Authenticate – korrekt.']
? ['ok' => true, 'message' => 'Erreichbar; verlangt wie erwartet eine Anmeldung (HTTP 401).']
: ['ok' => false, 'message' => sprintf('Erwartet 401 mit WWW-Authenticate, erhalten HTTP %d.', $code)];
}
@@ -132,14 +132,19 @@ class ConnectionStatus
: __('Website läuft ohne HTTPS – claude.ai verbindet sich nur über HTTPS', 'wp-lit-mcp'),
],
[
'label' => __('Discovery (/.well-known/)', 'wp-lit-mcp'),
'label' => __('OAuth-Discovery', 'wp-lit-mcp'),
'ok' => $discovery === null ? null : ($discovery['status'] ?? '') === 'good',
'message' => $discovery === null
? __('noch nicht geprüft', 'wp-lit-mcp')
: wp_strip_all_tags((string) ($discovery['label'] ?? '')),
'message' => match (true) {
$discovery === null => __('noch nicht geprüft', 'wp-lit-mcp'),
($discovery['status'] ?? '') === 'good' => __('Discovery-Dokumente erreichbar', 'wp-lit-mcp'),
default => __(
'Discovery-Dokumente (/.well-known/) nicht erreichbar – Details unter Website-Zustand',
'wp-lit-mcp'
),
},
],
[
'label' => __('Endpunkt ohne Token', 'wp-lit-mcp'),
'label' => __('MCP-Endpunkt', 'wp-lit-mcp'),
'ok' => $endpoint['ok'] ?? null,
'message' => $endpoint['message'] ?? __('noch nicht geprüft', 'wp-lit-mcp'),
],
+38 -14
View File
@@ -30,29 +30,53 @@ final class ConnectionTab implements AdminTab
public function render(): void
{
echo '<h2>' . esc_html__('MCP-Server', 'wp-lit-mcp') . '</h2>';
echo '<h2>' . esc_html__('So verbindest du Claude', 'wp-lit-mcp') . '</h2>';
echo '<ol class="lit-mcp-steps"><li>';
printf(
'<p><label for="lit-mcp-url">%s</label><br>'
. '<input id="lit-mcp-url" type="text" class="large-text code" readonly value="%s"></p>',
esc_html__('Server-URL für den Connector', 'wp-lit-mcp'),
esc_attr(ConnectionStatus::serverUrl())
'<label for="lit-mcp-url">%s</label>'
. '<span class="lit-mcp-copy-row copy-to-clipboard-container">'
. '<input id="lit-mcp-url" type="text" class="large-text code" readonly value="%s">'
. '<button type="button" class="button lit-mcp-copy" data-clipboard-target="#lit-mcp-url">%s</button>'
. '<span class="success hidden" aria-hidden="true">%s</span></span>',
esc_html__('Server-URL kopieren:', 'wp-lit-mcp'),
esc_attr(ConnectionStatus::serverUrl()),
esc_html__('URL kopieren', 'wp-lit-mcp'),
esc_html__('Kopiert!', 'wp-lit-mcp')
);
echo '<p>' . esc_html__(
'In claude.ai unter Einstellungen → Connectors → „Add custom connector“ eintragen;',
echo '</li><li>' . esc_html__(
'In claude.ai unter Einstellungen → Connectors auf „Add custom connector“ klicken und die URL einfügen.',
'wp-lit-mcp'
) . ' ' . esc_html__('Die Anmeldung läuft danach über diese Website.', 'wp-lit-mcp') . '</p>';
) . '</li><li>';
printf(
/* translators: %s: link to the "Zugriff" tab */
esc_html__('Anmelden und den Zugriff bestätigen. Danach erscheint die Verbindung unter %s.', 'wp-lit-mcp'),
sprintf('<a href="%s">%s</a>', esc_url(SettingsPage::url('zugriff')), esc_html__('Zugriff', 'wp-lit-mcp'))
);
echo '</li></ol>';
echo '<h2>' . esc_html__('Status', 'wp-lit-mcp') . '</h2><table class="widefat striped"><tbody>';
echo '<h2>' . esc_html__('Status', 'wp-lit-mcp') . '</h2><ul class="lit-mcp-status">';
foreach ($this->status->checks(true) as $check) {
$state = $check['ok'] === null ? '–' : ($check['ok'] ? '✔' : '✖');
[$icon, $state] = match ($check['ok']) {
true => ['yes-alt', __('In Ordnung:', 'wp-lit-mcp')],
false => ['dismiss', __('Problem:', 'wp-lit-mcp')],
default => ['warning', __('Hinweis:', 'wp-lit-mcp')],
};
printf(
'<tr><th scope="row">%s</th><td>%s</td><td>%s</td></tr>',
esc_html($check['label']),
'<li class="lit-mcp-status-%1$s"><span class="dashicons dashicons-%1$s" aria-hidden="true"></span>'
. '<span class="screen-reader-text">%2$s</span> <strong>%3$s</strong>'
. ' <span class="lit-mcp-status-detail">%4$s</span></li>',
esc_attr($icon),
esc_html($state),
esc_html($check['label']),
esc_html($check['message'])
);
}
echo '</tbody></table>';
echo '<p>' . esc_html__('Weitere Prüfungen unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp') . '</p>';
echo '</ul>';
printf(
'<p class="description">%s <a href="%s">%s</a>.</p>',
esc_html__('Weitere Prüfungen unter', 'wp-lit-mcp'),
esc_url(admin_url('site-health.php')),
esc_html__('Werkzeuge → Website-Zustand', 'wp-lit-mcp')
);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\Server\ServerRegistrar;
use Throwable;
use WP_REST_Request;
use WPMedia\MCP\OAuth\Auth\JWT;
use WPMedia\MCP\OAuth\Auth\SecretManager;
/**
* Records when an OAuth connection was last used, for "Zuletzt genutzt" in the tab "Zugriff".
*
* mcp-oauth authenticates with a JWT bound to an application password but never records
* its usage (WordPress only does that for Basic-Auth application passwords), so last_used
* stayed empty. After an authenticated request to our MCP route this calls WordPress' own
* WP_Application_Passwords::record_application_password_usage(), which writes at most once
* a day per password – the same data and throttle as in the user's profile.
*/
final class ConnectionUsage
{
/** @var callable(string): ?array<string, mixed> */
private $decode;
/** @var callable(int, string): mixed */
private $recordUsage;
/**
* @param (callable(string): ?array<string, mixed>)|null $decode JWT → claims, null if invalid.
* @param (callable(int, string): mixed)|null $recordUsage user id, application password uuid.
*/
public function __construct(?callable $decode = null, ?callable $recordUsage = null)
{
$this->decode = $decode ?? static function (string $token): ?array {
$claims = JWT::decode($token, SecretManager::get_secret());
return is_array($claims) ? $claims : null;
};
$this->recordUsage = $recordUsage ?? static function (int $userId, string $uuid): void {
\WP_Application_Passwords::record_application_password_usage($userId, $uuid);
};
}
public function register(): void
{
add_filter('rest_request_after_callbacks', [$this, 'record'], 10, 3);
}
/**
* @param array<string, mixed> $handler
* @return mixed The response, unchanged.
*/
public function record(mixed $response, array $handler, WP_REST_Request $request): mixed
{
$route = '/' . ServerRegistrar::NAMESPACE . '/' . ServerRegistrar::ROUTE;
if ($request->get_route() !== $route || is_wp_error($response)) {
return $response;
}
$header = $request->get_header('authorization');
$userId = get_current_user_id();
if (! is_string($header) || stripos($header, 'Bearer ') !== 0 || $userId <= 0) {
return $response;
}
try {
$claims = ($this->decode)(substr($header, 7));
$uuid = is_array($claims) ? (string) ($claims['app_pass_id'] ?? '') : '';
// Only the password of the user this request actually authenticated as.
if ($uuid !== '' && (int) ($claims['sub'] ?? 0) === $userId) {
($this->recordUsage)($userId, $uuid);
}
} catch (Throwable) {
// Bookkeeping only – never break the MCP response.
}
return $response;
}
}
+37 -19
View File
@@ -7,7 +7,8 @@ namespace LIT\MCP\Admin;
use LIT\MCP\Limits;
/**
* Tab "Limits": upload/download size, log retention, snapshot count (spec §8.4).
* Tab "Limits": upload/download size and log retention (spec §8.4). The snapshot count is
* kept as a hidden field until the design system feature that uses it exists.
* Changes apply immediately – Limits::all() always reads the current option.
*/
final class LimitsTab implements AdminTab
@@ -41,39 +42,56 @@ final class LimitsTab implements AdminTab
{
$values = (new Limits())->all();
$bounds = Limits::bounds();
$labels = [
'upload_mb' => sprintf(
/* translators: 1: maximum value in MB, 2: cap for Base64 transfers in MB */
__(
'Maximale Upload-/Download-Größe (MB, höchstens %1$d; Base64-Upload/Download höchstens %2$d MB)',
'wp-lit-mcp'
$fields = [
'upload_mb' => [
__('Maximale Dateigröße', 'wp-lit-mcp'),
__('MB', 'wp-lit-mcp'),
sprintf(
/* translators: 1: maximum value in MB, 2: cap for Base64 transfers in MB */
__('Höchstens %1$d MB; für Base64-Übertragungen höchstens %2$d MB.', 'wp-lit-mcp'),
$bounds['upload_mb'][1],
Limits::INLINE_MAX_MB
),
$bounds['upload_mb'][1],
Limits::INLINE_MAX_MB
),
'log_retention_days' => __('Protokoll aufbewahren (Tage)', 'wp-lit-mcp'),
'snapshot_count' => __('Anzahl Designsystem-Snapshots (ab Etappe 4)', 'wp-lit-mcp'),
],
'log_retention_days' => [
__('Protokoll aufbewahren', 'wp-lit-mcp'),
__('Tage', 'wp-lit-mcp'),
__('Ältere Einträge im Protokoll werden automatisch gelöscht.', 'wp-lit-mcp'),
],
];
echo '<form method="post" action="options.php">';
settings_fields(self::GROUP);
echo '<table class="form-table"><tbody>';
foreach ($labels as $key => $label) {
echo '<table class="form-table" role="presentation"><tbody>';
foreach ($fields as $key => [$label, $unit, $description]) {
[$min, $max] = $bounds[$key];
printf(
'<tr><th scope="row"><label for="lit-mcp-%1$s">%2$s</label></th><td>'
. '<input id="lit-mcp-%1$s" type="number" min="%3$d" max="%4$d" name="%5$s[%1$s]" value="%6$d">'
. '</td></tr>',
. '<input id="lit-mcp-%1$s" class="small-text" type="number" min="%3$d" max="%4$d"'
. ' name="%5$s[%1$s]" value="%6$d" aria-describedby="lit-mcp-%1$s-description">'
. ' <span class="lit-mcp-unit">%7$s</span>'
. '<p class="description" id="lit-mcp-%1$s-description">%8$s</p>',
esc_attr($key),
esc_html($label),
esc_attr((string) $min),
esc_attr((string) $max),
esc_attr(Limits::OPTION),
esc_attr((string) $values[$key])
esc_attr((string) $values[$key]),
esc_html($unit),
esc_html($description)
);
if ($key === 'upload_mb') {
$this->maybeRenderUploadSizeNotice($values['upload_mb']);
}
echo '</td></tr>';
}
echo '</tbody></table>';
$this->maybeRenderUploadSizeNotice($values['upload_mb']);
// Design system snapshots are not available yet: no visible setting, but keep the saved value.
printf(
'<input type="hidden" name="%s[snapshot_count]" value="%d">',
esc_attr(Limits::OPTION),
(int) $values['snapshot_count']
);
submit_button();
echo '</form>';
}
@@ -96,6 +114,6 @@ final class LimitsTab implements AdminTab
$uploadMb,
$serverMaxMb
);
printf('<p class="description">%s</p>', esc_html($message));
printf('<div class="notice notice-warning inline"><p>%s</p></div>', esc_html($message));
}
}
+75 -5
View File
@@ -18,13 +18,18 @@ final class LogListTable extends \WP_List_Table
{
private const PER_PAGE = 50;
/** @var (callable(): void)|null */
private $renderFilters;
/**
* @param array{
* ability: string, user_id: int, result?: string, date_from?: string, date_to?: string
* } $filters
* @param (callable(): void)|null $renderFilters prints the filter controls into the top table nav
*/
public function __construct(private ChangeLog $log, private array $filters)
public function __construct(private ChangeLog $log, private array $filters, ?callable $renderFilters = null)
{
$this->renderFilters = $renderFilters;
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
}
@@ -32,7 +37,7 @@ final class LogListTable extends \WP_List_Table
public function get_columns(): array
{
return [
'created_at' => __('Zeit (UTC)', 'wp-lit-mcp'),
'created_at' => __('Zeit', 'wp-lit-mcp'),
'user' => __('Benutzer', 'wp-lit-mcp'),
'client' => __('Client', 'wp-lit-mcp'),
'ability' => __('Tool', 'wp-lit-mcp'),
@@ -57,6 +62,22 @@ final class LogListTable extends \WP_List_Table
esc_html_e('Keine Einträge.', 'wp-lit-mcp');
}
/**
* Filters left of the pagination in the top table nav, as on WordPress' own list screens.
*
* @param string $which
*/
protected function extra_tablenav($which): void
{
if ($which !== 'top' || $this->renderFilters === null) {
return;
}
echo '<div class="alignleft actions">';
($this->renderFilters)();
echo '</div>';
}
/**
* Highlights rows with result "partial" (a write that failed after an irreversible
* step, spec §2 principle 7/8.2) so admins notice them without reading every row.
@@ -65,12 +86,61 @@ final class LogListTable extends \WP_List_Table
*/
public function single_row($item): void
{
$style = ($item['result'] ?? '') === 'partial' ? ' style="background:#fcf3e3;"' : '';
printf('<tr%s>', $style); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string.
$class = ($item['result'] ?? '') === 'partial' ? ' class="lit-mcp-partial"' : '';
printf('<tr%s>', $class); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string.
$this->single_row_columns($item);
echo '</tr>';
}
/**
* Stored in UTC (MySQL format); shown in the site's timezone and date/time format,
* with the UTC value as tooltip.
*
* @param array<string, mixed> $item
*/
protected function column_created_at($item): string
{
$utc = (string) ($item['created_at'] ?? '');
$timestamp = preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $utc) === 1
? strtotime($utc . ' UTC')
: false;
if ($timestamp === false) {
return '–';
}
$format = get_option('date_format') . ' ' . get_option('time_format');
return sprintf(
'<span title="%s">%s</span>',
esc_attr('UTC: ' . $utc),
esc_html((string) wp_date($format, $timestamp))
);
}
/** @param array<string, mixed> $item */
protected function column_ability($item): string
{
return '<span class="lit-mcp-tool-name">' . esc_html((string) ($item['ability'] ?? '')) . '</span>';
}
/** @param array<string, mixed> $item */
protected function column_result($item): string
{
$result = (string) ($item['result'] ?? '');
[$icon, $word] = match ($result) {
'ok' => ['yes-alt', __('erfolgreich', 'wp-lit-mcp')],
'partial' => ['warning', __('teilweise', 'wp-lit-mcp')],
default => ['dismiss', $result],
};
return sprintf(
'<span class="lit-mcp-result lit-mcp-result-%1$s">'
. '<span class="dashicons dashicons-%1$s" aria-hidden="true"></span> %2$s</span>',
esc_attr($icon),
esc_html($word)
);
}
/**
* @param array<string, mixed> $item
* @param string $column_name
@@ -144,7 +214,7 @@ final class LogListTable extends \WP_List_Table
return sprintf(
'<a href="%s">%s</a>',
esc_url(admin_url('revision.php?revision=' . $revisionId)),
esc_html__('ansehen / wiederherstellen', 'wp-lit-mcp')
esc_html__('Revision ansehen', 'wp-lit-mcp')
);
}
}
+51 -28
View File
@@ -94,21 +94,34 @@ final class LogTab implements AdminTab
$filters = $this->filters();
$extra = $this->extraFilters();
$days = $this->limits->logRetentionDays();
printf(
'<p>%s</p>',
esc_html(sprintf(
/* translators: %d: number of days */
__('Einträge werden nach %d Tagen automatisch gelöscht (einstellbar im Tab „Limits“).', 'wp-lit-mcp'),
$this->limits->logRetentionDays()
))
'<p class="description">%s %s</p>',
esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp'),
sprintf(
/* translators: %s: link "N Tagen" to the Limits tab */
esc_html__('Einträge werden nach %s automatisch gelöscht.', 'wp-lit-mcp'),
sprintf(
'<a href="%s">%s</a>',
esc_url(SettingsPage::url('limits')),
esc_html(sprintf(
/* translators: %d: number of days */
_n('%d Tag', '%d Tagen', $days, 'wp-lit-mcp'),
$days
))
)
)
);
printf('<p>%s</p>', esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp'));
$this->renderFilterForm($filters, $extra);
$table = new LogListTable($this->log, $filters + $extra);
echo '<form method="get">';
printf('<input type="hidden" name="page" value="%s">', esc_attr(SettingsPage::SLUG));
printf('<input type="hidden" name="tab" value="%s">', esc_attr($this->slug()));
$table = new LogListTable($this->log, $filters + $extra, function () use ($filters, $extra): void {
$this->renderFilterControls($filters, $extra);
});
$table->prepare_items();
$table->display();
echo '</form>';
$this->renderClearForm();
}
@@ -117,13 +130,11 @@ final class LogTab implements AdminTab
* @param array{ability: string, user_id: int} $filters
* @param array{result: string, date_from: string, date_to: string} $extra
*/
private function renderFilterForm(array $filters, array $extra): void
private function renderFilterControls(array $filters, array $extra): void
{
echo '<form method="get">';
printf('<input type="hidden" name="page" value="%s">', esc_attr(SettingsPage::SLUG));
printf('<input type="hidden" name="tab" value="%s">', esc_attr($this->slug()));
echo '<select name="ability"><option value="">' . esc_html__('Alle Tools', 'wp-lit-mcp') . '</option>';
self::filterLabel('ability', __('Nach Tool filtern', 'wp-lit-mcp'));
echo '<select name="ability" id="lit-mcp-filter-ability"><option value="">'
. esc_html__('Alle Tools', 'wp-lit-mcp') . '</option>';
foreach ($this->log->abilities() as $ability) {
printf(
'<option value="%1$s"%2$s>%1$s</option>',
@@ -131,39 +142,51 @@ final class LogTab implements AdminTab
selected($filters['ability'], $ability, false)
);
}
echo '</select> ';
echo '</select>';
self::filterLabel('user-id', __('Nach Benutzer filtern', 'wp-lit-mcp'));
wp_dropdown_users([
'name' => 'user_id',
'id' => 'lit-mcp-filter-user-id',
'show_option_all' => __('Alle Benutzer', 'wp-lit-mcp'),
'selected' => $filters['user_id'],
]);
echo ' ';
echo '<select name="result">';
self::filterLabel('result', __('Nach Ergebnis filtern', 'wp-lit-mcp'));
echo '<select name="result" id="lit-mcp-filter-result">';
printf('<option value="">%s</option>', esc_html__('Alle Ergebnisse', 'wp-lit-mcp'));
$labels = ['ok' => __('erfolgreich', 'wp-lit-mcp'), 'partial' => __('teilweise', 'wp-lit-mcp')];
foreach (self::RESULTS as $result) {
printf(
'<option value="%1$s"%2$s>%1$s</option>',
'<option value="%s"%s>%s</option>',
esc_attr($result),
selected($extra['result'], $result, false)
selected($extra['result'], $result, false),
esc_html($labels[$result])
);
}
echo '</select> ';
echo '</select>';
self::filterLabel('date-from', __('Von Datum', 'wp-lit-mcp'));
printf(
'<label>%s <input type="date" name="date_from" value="%s"></label> ',
esc_html__('von', 'wp-lit-mcp'),
'<input type="date" name="date_from" id="lit-mcp-filter-date-from" value="%s">',
esc_attr($extra['date_from'])
);
self::filterLabel('date-to', __('Bis Datum', 'wp-lit-mcp'));
printf(
'<label>%s <input type="date" name="date_to" value="%s"></label> ',
esc_html__('bis', 'wp-lit-mcp'),
'<input type="date" name="date_to" id="lit-mcp-filter-date-to" value="%s">',
esc_attr($extra['date_to'])
);
submit_button(__('Filtern', 'wp-lit-mcp'), 'secondary', '', false);
echo '</form>';
submit_button(__('Filtern', 'wp-lit-mcp'), '', 'filter_action', false, ['id' => 'lit-mcp-filter-submit']);
}
private static function filterLabel(string $field, string $text): void
{
printf(
'<label class="screen-reader-text" for="lit-mcp-filter-%s">%s</label>',
esc_attr($field),
esc_html($text)
);
}
private function renderClearForm(): void
+48 -12
View File
@@ -11,15 +11,24 @@ final class SettingsPage
{
public const SLUG = 'lit-mcp';
/** Hook suffix WordPress gives this page (add_options_page → "settings_page_<slug>"). */
public const SCREEN_HOOK = 'settings_page_' . self::SLUG;
/** @var array<string, AdminTab> */
private array $tabs = [];
/**
* @param list<AdminTab> $tabs The first tab is the default.
* @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none).
* @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none).
* @param string $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
* @param string $version Asset version for cache busting.
*/
public function __construct(array $tabs, private string $logoUrl = '')
{
public function __construct(
array $tabs,
private string $logoUrl = '',
private string $assetsUrl = '',
private string $version = ''
) {
foreach ($tabs as $tab) {
$this->tabs[$tab->slug()] = $tab;
}
@@ -29,6 +38,7 @@ final class SettingsPage
{
add_action('admin_menu', [$this, 'addMenu']);
add_action('admin_init', [$this, 'registerSettings']);
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
foreach ($this->tabs as $tab) {
$tab->registerActions();
}
@@ -52,6 +62,29 @@ final class SettingsPage
}
}
/**
* Own CSS and a small script, only on this page. The script builds on WordPress' own
* clipboard.js and wp.a11y.speak (as the media modal's "Copy URL") and postbox.js.
*/
public function enqueueAssets(string $hook): void
{
if ($hook !== self::SCREEN_HOOK || $this->assetsUrl === '') {
return;
}
$base = rtrim($this->assetsUrl, '/');
wp_enqueue_style('lit-mcp-admin', $base . '/css/admin.css', [], $this->version);
$deps = ['clipboard', 'wp-a11y'];
if ($this->currentTab() === 'tools') {
// Collapsible tool groups use WordPress' own postbox toggles (remembered per user).
$deps[] = 'postbox';
}
wp_enqueue_script('lit-mcp-admin', $base . '/js/admin.js', $deps, $this->version, true);
wp_localize_script('lit-mcp-admin', 'litMcpAdmin', [
'copied' => __('Die URL wurde in die Zwischenablage kopiert.', 'wp-lit-mcp'),
]);
}
/** @return list<string> */
public function tabSlugs(): array
{
@@ -81,16 +114,19 @@ final class SettingsPage
}
$current = $this->currentTab();
echo '<div class="wrap">';
echo '<div class="wrap lit-mcp">';
echo '<div class="lit-mcp-header"><h1>' . esc_html__('LIT MCP', 'wp-lit-mcp') . '</h1>';
$this->renderLogo();
echo '<h1>' . esc_html__('LIT MCP', 'wp-lit-mcp') . '</h1>';
// Admin notices of other plugins are moved right after this marker – above the tabs.
echo '</div><hr class="wp-header-end">';
$this->renderNotice();
echo '<nav class="nav-tab-wrapper">';
printf('<nav class="nav-tab-wrapper wp-clearfix" aria-label="%s">', esc_attr__('Bereiche', 'wp-lit-mcp'));
foreach ($this->tabs as $slug => $tab) {
printf(
'<a href="%s" class="nav-tab%s">%s</a>',
'<a href="%s" class="nav-tab%s"%s>%s</a>',
esc_url(self::url($slug)),
$slug === $current ? ' nav-tab-active' : '',
$slug === $current ? ' aria-current="page"' : '',
esc_html($tab->label())
);
}
@@ -106,11 +142,11 @@ final class SettingsPage
}
printf(
'<div class="lit-mcp-header"><a href="https://loosen-it.de" target="_blank" rel="noopener">'
. '<img src="%s" alt="Loosen-IT Logo" class="lit-mcp-logo"></a></div>'
. '<style>.lit-mcp-header{position:relative}.lit-mcp-logo{position:absolute;padding:10px;right:0;'
. 'top:0;height:30px;width:auto}@media (max-width:782px){.lit-mcp-logo{height:24px}}</style>',
esc_url($this->logoUrl)
'<a class="lit-mcp-logo-link" href="https://loosen-it.de" target="_blank" rel="noopener">'
. '<img src="%s" alt="Loosen-IT Logo" class="lit-mcp-logo">'
. '<span class="screen-reader-text"> %s</span></a>',
esc_url($this->logoUrl),
esc_html__('(öffnet in einem neuen Tab)', 'wp-lit-mcp')
);
}
+198 -69
View File
@@ -61,79 +61,177 @@ final class ToolsTab implements AdminTab
public function render(): void
{
echo '<p>'
. esc_html__('Direkt: sofort als eigenes Tool sichtbar.', 'wp-lit-mcp') . ' '
. esc_html__('Indirekt: nur über die Meta-Tools (discover/execute) erreichbar.', 'wp-lit-mcp') . ' '
. esc_html__('Deaktiviert: gar nicht verfügbar.', 'wp-lit-mcp')
. '</p>';
echo '<p>'
. esc_html__(
'Die Server-Anweisungen beim Verbinden nennen immer die aktuell direkt sichtbaren Tools.',
'wp-lit-mcp'
) . ' '
. esc_html__(
'Änderungen gelten also ab der nächsten Verbindung; in claude.ai ggf. den Connector neu verbinden.',
'wp-lit-mcp'
)
. '</p>';
echo '<p>' . esc_html__(
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
'wp-lit-mcp'
) . '</p>';
$policy = $this->policy->withGroups($this->registry->groupMap());
$groups = $this->registry->grouped();
$this->renderIntro(isset($groups['divi']));
$this->renderCounter(count($policy->directNames($this->registry->abilities())));
// Lets WordPress' postbox script remember which groups each user closed.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core nonce markup.
echo wp_nonce_field('closedpostboxes', 'closedpostboxesnonce', false, false);
echo '<form method="post" action="options.php">';
settings_fields(self::GROUP);
foreach ($this->registry->grouped() as $group => $abilities) {
$this->renderGroup($policy, $group, $abilities);
submit_button(null, 'primary', 'submit-top');
echo '<div class="lit-mcp-groups">';
$closed = $this->savedClosedBoxes();
foreach ($groups as $group => $abilities) {
$this->renderGroup($policy, $group, $abilities, $closed);
}
echo '</div>';
submit_button();
echo '</form>';
}
private function renderCounter(int $direct): void
private function renderIntro(bool $diviAvailable): void
{
echo '<p><strong>' . esc_html(sprintf(
/* translators: %d: number of direct tools */
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
$direct
)) . '</strong></p>';
echo '<p class="description">'
. esc_html__('Lege fest, welche Tools Claude nutzen kann.', 'wp-lit-mcp') . ' '
. esc_html__('Änderungen gelten ab der nächsten Verbindung.', 'wp-lit-mcp') . ' '
. esc_html__('In claude.ai den Connector dafür gegebenenfalls neu verbinden.', 'wp-lit-mcp')
. '</p>';
if ($direct > self::DIRECT_WARNING_THRESHOLD) {
echo '<p class="description"><strong>' . esc_html__('Achtung:', 'wp-lit-mcp') . '</strong> '
. esc_html__(
'Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.',
'wp-lit-mcp'
) . ' '
. esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp')
. '</p>';
echo '<dl class="lit-mcp-legend">';
$legend = [
__('Direkt', 'wp-lit-mcp') => __('Claude sieht das Tool sofort.', 'wp-lit-mcp'),
__('Indirekt', 'wp-lit-mcp') => __('Claude findet es bei Bedarf über die Tool-Suche.', 'wp-lit-mcp'),
__('Aus', 'wp-lit-mcp') => __('Nicht verfügbar.', 'wp-lit-mcp'),
__('Nur lesen', 'wp-lit-mcp') => __('Schaltet alle schreibenden Tools einer Gruppe aus.', 'wp-lit-mcp'),
];
foreach ($legend as $term => $text) {
printf('<dt>%s</dt><dd>%s</dd>', esc_html($term), esc_html($text));
}
echo '</dl>';
if ($diviAvailable) {
echo '<p class="description">' . esc_html__(
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
'wp-lit-mcp'
) . '</p>';
}
}
/** @param list<Ability> $abilities */
private function renderGroup(ExposurePolicy $policy, string $group, array $abilities): void
private function renderCounter(int $direct): void
{
$count = count($abilities);
$mixed = false;
foreach ($abilities as $ability) {
$mixed = $mixed || $policy->hasToolOverride($ability->name());
}
$warn = $direct > self::DIRECT_WARNING_THRESHOLD;
$counter = sprintf(
/* translators: %d: number of direct tools */
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
$direct
);
printf(
'<details open class="lit-mcp-tool-group"><summary>%s</summary>',
esc_html(sprintf(
/* translators: 1: group label, 2: number of tools */
_n('%1$s (%2$d Tool)', '%1$s (%2$d Tools)', $count, 'wp-lit-mcp'),
$this->groupLabel($group),
$count
))
'<div class="notice notice-%s inline"><p><strong>%s</strong>',
$warn ? 'warning' : 'info',
esc_html($counter)
);
$groupMode = $policy->groupMode($group);
if ($warn) {
echo ' – '
. esc_html__('Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.', 'wp-lit-mcp')
. ' '
. esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp');
}
echo '</p></div>';
}
/**
* Closed postbox ids the user saved via WordPress' postbox script, or null if never saved.
*
* @return list<string>|null
*/
private function savedClosedBoxes(): ?array
{
$saved = get_user_option('closedpostboxes_' . SettingsPage::SCREEN_HOOK);
return is_array($saved) ? array_values(array_filter($saved, 'is_string')) : null;
}
/**
* @param list<Ability> $abilities
* @param list<string>|null $closedBoxes
*/
private function renderGroup(ExposurePolicy $policy, string $group, array $abilities, ?array $closedBoxes): void
{
$count = count($abilities);
$label = $this->groupLabel($group);
$boxId = 'lit-mcp-group-' . self::idPart($group);
$modes = [ExposurePolicy::DIRECT => 0, ExposurePolicy::INDIRECT => 0, ExposurePolicy::DISABLED => 0];
$mixed = false;
foreach ($abilities as $ability) {
$modes[$policy->mode($ability)]++;
$mixed = $mixed || $policy->hasToolOverride($ability->name());
}
// Groups start open; one whose tools are all off may start closed. A saved user choice wins.
$closed = $closedBoxes === null
? $count > 0 && $modes[ExposurePolicy::DISABLED] === $count
: in_array($boxId, $closedBoxes, true);
printf('<div id="%s" class="postbox lit-mcp-group%s">', esc_attr($boxId), $closed ? ' closed' : '');
echo '<div class="postbox-header">';
printf(
'<p><label>%s <select name="%s[%s]">',
esc_html__('Ganze Gruppe:', 'wp-lit-mcp'),
'<h2 class="hndle">%s <span class="count">(%s)</span> <span class="lit-mcp-group-summary">%s</span></h2>',
esc_html($label),
esc_html(sprintf(
/* translators: %d: number of tools */
_n('%d Tool', '%d Tools', $count, 'wp-lit-mcp'),
$count
)),
esc_html($this->summary($modes, $policy->isReadonly($group)))
);
$this->renderGroupControls($policy, $group, $mixed);
printf(
'<div class="handle-actions hide-if-no-js"><button type="button" class="handlediv" aria-expanded="%s">'
. '<span class="screen-reader-text">%s</span>'
. '<span class="toggle-indicator" aria-hidden="true"></span></button></div>',
$closed ? 'false' : 'true',
esc_html(sprintf(
/* translators: %s: group label */
__('Gruppe ein-/ausblenden: %s', 'wp-lit-mcp'),
$label
))
);
echo '</div><div class="inside">';
printf(
'<table class="widefat striped lit-mcp-tools-table"><thead><tr>'
. '<th scope="col" class="column-tool">%s</th><th scope="col" class="column-kind">%s</th>'
. '<th scope="col" class="column-mode">%s</th></tr></thead><tbody>',
esc_html__('Tool', 'wp-lit-mcp'),
esc_html__('Art', 'wp-lit-mcp'),
esc_html__('Einstellung', 'wp-lit-mcp')
);
$groupMode = $policy->groupMode($group);
foreach ($abilities as $ability) {
$this->renderTool($policy, $ability, $groupMode !== null);
}
echo '</tbody></table></div></div>';
}
/** @param array<string, int> $modes */
private function summary(array $modes, bool $readonly): string
{
$summary = sprintf(
/* translators: 1: number of direct tools, 2: indirect, 3: off */
__('%1$d direkt · %2$d indirekt · %3$d aus', 'wp-lit-mcp'),
$modes[ExposurePolicy::DIRECT],
$modes[ExposurePolicy::INDIRECT],
$modes[ExposurePolicy::DISABLED]
);
return $readonly ? __('nur lesen', 'wp-lit-mcp') . ' · ' . $summary : $summary;
}
private function renderGroupControls(ExposurePolicy $policy, string $group, bool $mixed): void
{
$selectId = 'lit-mcp-group-mode-' . self::idPart($group);
$groupMode = $policy->groupMode($group);
echo '<div class="lit-mcp-group-controls">';
printf(
'<span class="lit-mcp-control"><label for="%1$s">%2$s</label> <select name="%3$s[%4$s]" id="%1$s">',
esc_attr($selectId),
esc_html__('Ganze Gruppe', 'wp-lit-mcp'),
esc_attr(ExposurePolicy::OPTION),
esc_attr(ExposurePolicy::GROUP_PREFIX . $group)
);
@@ -150,25 +248,20 @@ final class ToolsTab implements AdminTab
esc_html($this->modeLabel($mode))
);
}
echo '</select></label> ';
echo '</select></span>';
printf(
'<label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label>',
'<span class="lit-mcp-control"><label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label></span>',
esc_attr(ExposurePolicy::OPTION),
esc_attr(ExposurePolicy::READONLY_PREFIX . $group),
checked($policy->isReadonly($group), true, false),
esc_html__('nur lesen (schreibende Tools dieser Gruppe aus)', 'wp-lit-mcp')
esc_html__('Nur lesen', 'wp-lit-mcp')
);
if ($mixed) {
echo ' <em>' . esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp') . '</em>';
echo '<span class="description">'
. esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp')
. '</span>';
}
echo '</p>';
echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Tool', 'wp-lit-mcp') . '</th><th>'
. esc_html__('Einstellung', 'wp-lit-mcp') . '</th></tr></thead><tbody>';
foreach ($abilities as $ability) {
$this->renderTool($policy, $ability, $groupMode !== null);
}
echo '</tbody></table></details>';
echo '</div>';
}
private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void
@@ -177,11 +270,23 @@ final class ToolsTab implements AdminTab
$own = $policy->toolOverride($name);
$locked = $policy->disabledByReadonly($ability);
$required = in_array($name, ExposurePolicy::REQUIRED, true);
$selectId = 'lit-mcp-tool-' . self::idPart($name);
printf(
'<tr><td><strong>%s</strong><br><code>%s</code></td><td><select name="%s[%s]"%s>',
'<tr><td class="column-tool"><strong>%1$s</strong><br><span class="lit-mcp-tool-name">%2$s</span></td>'
. '<td class="column-kind">%3$s</td><td class="column-mode">'
. '<label class="screen-reader-text" for="%4$s">%5$s</label>'
. '<select name="%6$s[%7$s]"%8$s id="%4$s">',
esc_html($ability->label()),
esc_html($name),
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in kind().
$this->kind($ability),
esc_attr($selectId),
esc_html(sprintf(
/* translators: %s: tool label */
__('Einstellung für %s', 'wp-lit-mcp'),
$ability->label()
)),
esc_attr(ExposurePolicy::OPTION),
esc_attr($name),
$locked ? ' disabled' : ''
@@ -220,17 +325,41 @@ final class ToolsTab implements AdminTab
}
$note = match (true) {
$locked => __('durch ‚nur lesen‘ aus', 'wp-lit-mcp'),
$locked => __('Durch „Nur lesen“ aus', 'wp-lit-mcp'),
$own === null && $policy->groupModeAdjusted($ability)
=> __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'),
default => '',
};
if ($note !== '') {
echo ' <em>' . esc_html($note) . '</em>';
echo '<p class="description">' . esc_html($note) . '</p>';
}
echo '</td></tr>';
}
/** "liest" / "schreibt" / "löscht" from the ability's annotations, with a dashicon. */
private function kind(Ability $ability): string
{
$annotations = $ability->annotations();
[$icon, $text] = match (true) {
($annotations['destructive'] ?? false) === true => ['trash', __('löscht', 'wp-lit-mcp')],
($annotations['readonly'] ?? false) === true => ['visibility', __('liest', 'wp-lit-mcp')],
default => ['edit', __('schreibt', 'wp-lit-mcp')],
};
return sprintf(
'<span class="lit-mcp-kind" title="%2$s"><span class="dashicons dashicons-%1$s" aria-hidden="true"></span>'
. ' <span class="lit-mcp-kind-text">%2$s</span></span>',
esc_attr($icon),
esc_html($text)
);
}
/** Safe fragment for an HTML id from a group id or tool name ("lit/guide" → "lit-guide"). */
private static function idPart(string $value): string
{
return trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($value)), '-');
}
private function groupLabel(string $group): string
{
return match ($group) {
@@ -248,7 +377,7 @@ final class ToolsTab implements AdminTab
return match ($mode) {
ExposurePolicy::DIRECT => __('direkt', 'wp-lit-mcp'),
ExposurePolicy::INDIRECT => __('indirekt', 'wp-lit-mcp'),
default => __('deaktiviert', 'wp-lit-mcp'),
default => __('aus', 'wp-lit-mcp'),
};
}
}
+10 -2
View File
@@ -11,6 +11,7 @@ use LIT\MCP\Admin\AdminNotices;
use LIT\MCP\Admin\ConnectionRepository;
use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\Admin\ConnectionTab;
use LIT\MCP\Admin\ConnectionUsage;
use LIT\MCP\Admin\LimitsTab;
use LIT\MCP\Admin\LogTab;
use LIT\MCP\Admin\SettingsPage;
@@ -87,13 +88,20 @@ final class Plugin
$status = new ConnectionStatus();
(new SiteHealth($status))->register();
self::$settingsPage = new SettingsPage([
$tabs = [
new ConnectionTab($status),
new AccessTab($access, new ConnectionRepository()),
new ToolsTab($registry, $policy),
new LogTab($changeLog, $limits),
new LimitsTab(),
], plugins_url('assets/img/loosen-it-logo.svg', self::$pluginFile));
];
self::$settingsPage = new SettingsPage(
$tabs,
plugins_url('assets/img/loosen-it-logo.svg', self::$pluginFile),
plugins_url('assets', self::$pluginFile),
self::VERSION
);
(new ConnectionUsage())->register();
if (is_admin()) {
self::$settingsPage->register();
(new AdminNotices($status))->register();