From 843f45aeecd81e94e2de6e941b4b8ffe4bcb0146 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 16:50:58 +0200 Subject: [PATCH] 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) --- assets/css/admin.css | 340 ++++++++++++++++++++++ assets/js/admin.js | 45 +++ src/Admin/AccessTab.php | 97 ++++-- src/Admin/ConnectionStatus.php | 17 +- src/Admin/ConnectionTab.php | 52 +++- src/Admin/ConnectionUsage.php | 81 ++++++ src/Admin/LimitsTab.php | 56 ++-- src/Admin/LogListTable.php | 80 ++++- src/Admin/LogTab.php | 79 +++-- src/Admin/SettingsPage.php | 60 +++- src/Admin/ToolsTab.php | 267 ++++++++++++----- src/Plugin.php | 12 +- tests/Stubs/WpListTable.php | 7 + tests/Unit/Admin/AccessTabTest.php | 98 +++++-- tests/Unit/Admin/ConnectionStatusTest.php | 24 +- tests/Unit/Admin/ConnectionTabTest.php | 76 +++++ tests/Unit/Admin/ConnectionUsageTest.php | 78 +++++ tests/Unit/Admin/LimitsTabTest.php | 23 +- tests/Unit/Admin/LogListTableTest.php | 64 +++- tests/Unit/Admin/LogTabTest.php | 16 +- tests/Unit/Admin/SettingsPageTest.php | 51 +++- tests/Unit/Admin/ToolsTabTest.php | 154 ++++++++-- 22 files changed, 1535 insertions(+), 242 deletions(-) create mode 100644 assets/css/admin.css create mode 100644 assets/js/admin.js create mode 100644 src/Admin/ConnectionUsage.php create mode 100644 tests/Unit/Admin/ConnectionTabTest.php create mode 100644 tests/Unit/Admin/ConnectionUsageTest.php diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..5007c14 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,340 @@ +/* Einstellungen → LIT MCP. Only small additions to WordPress' own admin styles. */ + +/* Header: title left, logo right; wraps instead of overlapping on narrow screens. */ +.lit-mcp-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; +} + +.lit-mcp-logo { + display: block; + height: 30px; + width: auto; +} + +@media screen and (max-width: 782px) { + .lit-mcp-logo { + height: 22px; + } +} + +.lit-mcp .nav-tab-wrapper { + margin-bottom: 1em; +} + +/* Muted secondary text (tool names, client URLs). */ +.lit-mcp-muted, +.lit-mcp-tool-name { + color: #646970; + font-size: 12px; + overflow-wrap: anywhere; +} + +.lit-mcp-tool-name { + font-family: Consolas, Monaco, monospace; +} + +/* Tab "Verbindung". */ +.lit-mcp-steps { + max-width: 50rem; +} + +.lit-mcp-steps li { + margin-bottom: 0.75em; +} + +.lit-mcp-copy-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + margin-top: 6px; +} + +.lit-mcp-copy-row input.large-text { + flex: 1 1 22rem; + width: auto; +} + +.lit-mcp-copy-row .success { + color: #007017; +} + +.lit-mcp-status { + margin: 0 0 1em; +} + +.lit-mcp-status li { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0 6px; + margin-bottom: 8px; +} + +.lit-mcp-status .dashicons { + align-self: center; +} + +.lit-mcp-status-detail { + color: #50575e; +} + +.lit-mcp-status-yes-alt .dashicons, +.lit-mcp-result-yes-alt .dashicons { + color: #00a32a; +} + +.lit-mcp-status-warning .dashicons, +.lit-mcp-result-warning .dashicons { + color: #dba617; +} + +.lit-mcp-status-dismiss .dashicons, +.lit-mcp-result-dismiss .dashicons { + color: #d63638; +} + +/* Tab "Zugriff". */ +.lit-mcp-connections + .description { + margin-top: 8px; +} + +.lit-mcp-connections .column-actions { + width: 8em; + text-align: right; +} + +@media screen and (max-width: 782px) { + .lit-mcp-connections .column-created { + display: none; + } + + .lit-mcp-connections .column-actions { + width: auto; + } + + .lit-mcp-copy-row input.large-text { + font-size: 13px; + } +} + +/* Tab "Tools": legend, collapsible groups (WordPress postboxes) with aligned columns. */ +.lit-mcp-legend { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 12px; + margin: 1em 0; +} + +.lit-mcp-legend dt { + font-weight: 600; +} + +.lit-mcp-legend dd { + margin: 0; +} + +.lit-mcp-groups .postbox-header { + flex-wrap: wrap; + gap: 0 12px; + padding-left: 12px; +} + +.lit-mcp-groups .postbox .hndle { + cursor: pointer; + justify-content: flex-start; + gap: 6px; + padding: 8px 0; + margin: 0; + font-size: 14px; + line-height: 1.4; +} + +.lit-mcp-groups .hndle .count { + color: #646970; + font-weight: 400; +} + +.lit-mcp-group-summary { + color: #646970; + font-size: 13px; + font-weight: 400; +} + +.lit-mcp-group-summary::before { + content: "– "; +} + +.lit-mcp-groups .postbox:not(.closed) .lit-mcp-group-summary { + display: none; +} + +.lit-mcp-group-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 16px; + padding: 6px 0; +} + +.lit-mcp-group-controls .description { + font-style: italic; +} + +.lit-mcp-groups .handle-actions { + padding-right: 6px; +} + +/* Same arrow indicator as WordPress' own meta boxes (core scopes it to .meta-box-sortables). */ +.lit-mcp-groups .postbox .toggle-indicator::before { + content: "\f142"; + content: "\f142" / ''; + display: inline-block; + font: normal 20px/1 dashicons; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-decoration: none; +} + +.lit-mcp-groups .postbox.closed .toggle-indicator::before { + content: "\f140"; + content: "\f140" / ''; +} + +.lit-mcp-groups .postbox .inside { + margin: 0; + padding: 0; +} + +.lit-mcp-tools-table { + table-layout: fixed; + border: 0; + box-shadow: none; +} + +.lit-mcp-tools-table .column-tool { + width: 55%; +} + +.lit-mcp-tools-table .column-kind { + width: 15%; +} + +.lit-mcp-tools-table .column-mode { + width: 30%; +} + +.lit-mcp-tools-table td { + vertical-align: middle; +} + +.lit-mcp-tools-table select { + max-width: 100%; +} + +.lit-mcp-tools-table .description { + margin: 4px 0 0; +} + +.lit-mcp-kind { + color: #50575e; + white-space: nowrap; +} + +.lit-mcp-kind .dashicons { + font-size: 16px; + width: 16px; + height: 16px; + vertical-align: text-bottom; + color: #787c82; +} + +@media screen and (max-width: 782px) { + .lit-mcp-group-controls { + order: 3; + flex-basis: 100%; + padding-bottom: 10px; + } + + .lit-mcp-tools-table .column-tool { + width: 45%; + } + + .lit-mcp-tools-table .column-kind { + width: 12%; + } + + .lit-mcp-tools-table .column-mode { + width: 43%; + } + + .lit-mcp-tools-table th, + .lit-mcp-tools-table td { + padding-left: 6px; + padding-right: 6px; + } + + /* Icon only on phones; the word stays for screen readers (and as tooltip-free fallback). */ + .lit-mcp-kind-text { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .lit-mcp-tools-table select { + width: 100%; + font-size: 14px; + padding-right: 20px; + background-position: right 2px top 55%; + } +} + +/* Tab "Protokoll". */ +.lit-mcp .wp-list-table .column-created_at { + width: 10em; +} + +.lit-mcp .wp-list-table .column-user, +.lit-mcp .wp-list-table .column-client { + width: 8em; +} + +.lit-mcp .wp-list-table .column-ability, +.lit-mcp .wp-list-table .column-object { + width: 11em; +} + +.lit-mcp .wp-list-table .column-revision, +.lit-mcp .wp-list-table .column-result { + width: 8em; +} + +.lit-mcp .wp-list-table .lit-mcp-partial { + background: #fcf9e8; +} + +.lit-mcp-result .dashicons { + font-size: 18px; + width: 18px; + height: 18px; + vertical-align: text-bottom; +} + +.lit-mcp .tablenav .actions select, +.lit-mcp .tablenav .actions input[type="date"] { + float: left; + margin-right: 6px; + max-width: 12.5rem; +} + +/* Tab "Limits". */ +.lit-mcp-unit { + margin-left: 4px; +} diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..e950819 --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,45 @@ +/* Einstellungen → LIT MCP: copy the server URL, collapsible tool groups, filter paging. */ +( function () { + 'use strict'; + + document.addEventListener( 'DOMContentLoaded', function () { + var hideTimeout; + + // "URL kopieren" – like WordPress' "Copy URL to clipboard" in the media library. + if ( window.ClipboardJS && document.querySelector( '.lit-mcp-copy' ) ) { + new window.ClipboardJS( '.lit-mcp-copy' ).on( 'success', function ( event ) { + var success = event.trigger.parentNode.querySelector( '.success' ); + + event.clearSelection(); + event.trigger.focus(); + if ( success ) { + clearTimeout( hideTimeout ); + success.classList.remove( 'hidden' ); + hideTimeout = setTimeout( function () { + success.classList.add( 'hidden' ); + }, 3000 ); + } + if ( window.wp && window.wp.a11y && window.litMcpAdmin ) { + window.wp.a11y.speak( window.litMcpAdmin.copied ); + } + } ); + } + + // Tool groups: WordPress' own postbox toggles, closed state saved per user. + if ( window.postboxes && window.pagenow && document.querySelector( '.lit-mcp-groups' ) ) { + window.postboxes.add_postbox_toggles( window.pagenow ); + } + + // Protokoll: a changed filter starts again on page 1. + document.querySelectorAll( '.lit-mcp .tablenav .actions select, .lit-mcp .tablenav .actions input' ).forEach( + function ( control ) { + control.addEventListener( 'change', function () { + var paged = control.form && control.form.querySelector( 'input[name="paged"]' ); + if ( paged ) { + paged.value = '1'; + } + } ); + } + ); + } ); +}() ); diff --git a/src/Admin/AccessTab.php b/src/Admin/AccessTab.php index c183f59..5ddd9be 100644 --- a/src/Admin/AccessTab.php +++ b/src/Admin/AccessTab.php @@ -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 '

' . esc_html__('Freigegebene Rollen', 'wp-lit-mcp') . '

'; - echo '

' . esc_html__( - 'Nur Benutzer dieser Rollen können Tools nutzen; zusätzlich gelten ihre normalen WordPress-Rechte.', - 'wp-lit-mcp' - ) . '

'; echo '
'; settings_fields(self::GROUP); // Makes an all-unchecked submission arrive as [''] instead of dropping the field. printf('', esc_attr(Access::OPTION)); + printf( + ''; submit_button(); echo '
'; @@ -160,31 +165,42 @@ final class AccessTab implements AdminTab private function renderConnections(): void { - echo '

' . esc_html__('Aktive Verbindungen', 'wp-lit-mcp') . '

'; + echo '

' . esc_html__('Verbindungen', 'wp-lit-mcp') . '

'; $connections = $this->connections->all(); + + printf( + '' + . '' + . '' + . '' + . '', + 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 '

' . esc_html__('Keine aktiven Verbindungen.', 'wp-lit-mcp') . '

'; - return; + printf( + '', + 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 '
%s%s%s%s%s
%s %s %s
'; - $headings = [ - __('Benutzer', 'wp-lit-mcp'), - __('Client', 'wp-lit-mcp'), - __('Erstellt', 'wp-lit-mcp'), - __('Zuletzt genutzt', 'wp-lit-mcp'), - '', - ]; - foreach ($headings as $heading) { - echo ''; - } - echo ''; - foreach ($connections as $connection) { $this->renderConnectionRow($connection); } - echo '
' . esc_html($heading) . '
'; + + if ($connections !== []) { + echo '

' . esc_html__( + '„Zuletzt genutzt“ wird wie bei Anwendungspasswörtern höchstens einmal am Tag aktualisiert.', + 'wp-lit-mcp' + ) . '

'; + } } /** @@ -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 ''; - echo '' . esc_html($connection['user_login']) . ''; - printf('%s
%s', esc_html($connection['name']), esc_html($connection['app_id'])); - echo '' . esc_html((string) wp_date('Y-m-d H:i', $connection['created'])) . ''; + echo '' . esc_html($connection['user_login']) . ''; + printf( + '%s
%s', + esc_html($connection['name']), + esc_html($connection['app_id']) + ); + printf( + '%s', + 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 '' . esc_html($lastUsed) . ''; - echo ''; + : (string) wp_date($dateFormat, $connection['last_used']); + echo '' . esc_html($lastUsed) . ''; + echo ''; printf('
', esc_url(admin_url('admin-post.php'))); printf('', esc_attr(self::REVOKE_ACTION)); printf('', esc_attr((string) $connection['user_id'])); printf('', 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 '
'; } } diff --git a/src/Admin/ConnectionStatus.php b/src/Admin/ConnectionStatus.php index 89fe22e..08ba27d 100644 --- a/src/Admin/ConnectionStatus.php +++ b/src/Admin/ConnectionStatus.php @@ -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'), ], diff --git a/src/Admin/ConnectionTab.php b/src/Admin/ConnectionTab.php index 7f4ec63..1e30beb 100644 --- a/src/Admin/ConnectionTab.php +++ b/src/Admin/ConnectionTab.php @@ -30,29 +30,53 @@ final class ConnectionTab implements AdminTab public function render(): void { - echo '

' . esc_html__('MCP-Server', 'wp-lit-mcp') . '

'; + echo '

' . esc_html__('So verbindest du Claude', 'wp-lit-mcp') . '

'; + echo '
  1. '; printf( - '


    ' - . '

    ', - esc_html__('Server-URL für den Connector', 'wp-lit-mcp'), - esc_attr(ConnectionStatus::serverUrl()) + '' + . '' + . '' + . '' + . '', + 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 '

    ' . esc_html__( - 'In claude.ai unter Einstellungen → Connectors → „Add custom connector“ eintragen;', + echo '

  2. ' . 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') . '

    '; + ) . '
  3. '; + 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('%s', esc_url(SettingsPage::url('zugriff')), esc_html__('Zugriff', 'wp-lit-mcp')) + ); + echo '
'; - echo '

' . esc_html__('Status', 'wp-lit-mcp') . '

'; + echo '

' . esc_html__('Status', 'wp-lit-mcp') . '

', - esc_html($check['label']), + '
  • ' + . '%2$s %3$s' + . ' %4$s
  • ', + esc_attr($icon), esc_html($state), + esc_html($check['label']), esc_html($check['message']) ); } - echo '
    %s%s%s
    '; - echo '

    ' . esc_html__('Weitere Prüfungen unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp') . '

    '; + echo ''; + printf( + '

    %s %s.

    ', + esc_html__('Weitere Prüfungen unter', 'wp-lit-mcp'), + esc_url(admin_url('site-health.php')), + esc_html__('Werkzeuge → Website-Zustand', 'wp-lit-mcp') + ); } } diff --git a/src/Admin/ConnectionUsage.php b/src/Admin/ConnectionUsage.php new file mode 100644 index 0000000..8e51666 --- /dev/null +++ b/src/Admin/ConnectionUsage.php @@ -0,0 +1,81 @@ + */ + private $decode; + + /** @var callable(int, string): mixed */ + private $recordUsage; + + /** + * @param (callable(string): ?array)|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 $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; + } +} diff --git a/src/Admin/LimitsTab.php b/src/Admin/LimitsTab.php index f591d53..16183cc 100644 --- a/src/Admin/LimitsTab.php +++ b/src/Admin/LimitsTab.php @@ -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 '
    '; settings_fields(self::GROUP); - echo ''; - foreach ($labels as $key => $label) { + echo '
    '; + foreach ($fields as $key => [$label, $unit, $description]) { [$min, $max] = $bounds[$key]; printf( '', + . '' + . ' %7$s' + . '

    %8$s

    ', 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 ''; } echo ''; - $this->maybeRenderUploadSizeNotice($values['upload_mb']); + // Design system snapshots are not available yet: no visible setting, but keep the saved value. + printf( + '', + esc_attr(Limits::OPTION), + (int) $values['snapshot_count'] + ); submit_button(); echo '
    '; } @@ -96,6 +114,6 @@ final class LimitsTab implements AdminTab $uploadMb, $serverMaxMb ); - printf('

    %s

    ', esc_html($message)); + printf('

    %s

    ', esc_html($message)); } } diff --git a/src/Admin/LogListTable.php b/src/Admin/LogListTable.php index b4ce973..b07c966 100644 --- a/src/Admin/LogListTable.php +++ b/src/Admin/LogListTable.php @@ -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 '
    '; + ($this->renderFilters)(); + echo '
    '; + } + /** * 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('', $style); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string. + $class = ($item['result'] ?? '') === 'partial' ? ' class="lit-mcp-partial"' : ''; + printf('', $class); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string. $this->single_row_columns($item); echo ''; } + /** + * Stored in UTC (MySQL format); shown in the site's timezone and date/time format, + * with the UTC value as tooltip. + * + * @param array $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( + '%s', + esc_attr('UTC: ' . $utc), + esc_html((string) wp_date($format, $timestamp)) + ); + } + + /** @param array $item */ + protected function column_ability($item): string + { + return '' . esc_html((string) ($item['ability'] ?? '')) . ''; + } + + /** @param array $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( + '' + . ' %2$s', + esc_attr($icon), + esc_html($word) + ); + } + /** * @param array $item * @param string $column_name @@ -144,7 +214,7 @@ final class LogListTable extends \WP_List_Table return sprintf( '%s', esc_url(admin_url('revision.php?revision=' . $revisionId)), - esc_html__('ansehen / wiederherstellen', 'wp-lit-mcp') + esc_html__('Revision ansehen', 'wp-lit-mcp') ); } } diff --git a/src/Admin/LogTab.php b/src/Admin/LogTab.php index 7aeddc1..b411b0b 100644 --- a/src/Admin/LogTab.php +++ b/src/Admin/LogTab.php @@ -94,21 +94,34 @@ final class LogTab implements AdminTab $filters = $this->filters(); $extra = $this->extraFilters(); + $days = $this->limits->logRetentionDays(); printf( - '

    %s

    ', - 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() - )) + '

    %s %s

    ', + 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( + '%s', + esc_url(SettingsPage::url('limits')), + esc_html(sprintf( + /* translators: %d: number of days */ + _n('%d Tag', '%d Tagen', $days, 'wp-lit-mcp'), + $days + )) + ) + ) ); - printf('

    %s

    ', 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 '
    '; + printf('', esc_attr(SettingsPage::SLUG)); + printf('', 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 '
    '; $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 '
    '; - printf('', esc_attr(SettingsPage::SLUG)); - printf('', esc_attr($this->slug())); - - echo ''; foreach ($this->log->abilities() as $ability) { printf( '', @@ -131,39 +142,51 @@ final class LogTab implements AdminTab selected($filters['ability'], $ability, false) ); } - echo ' '; + echo ''; + 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 ''; printf('', esc_html__('Alle Ergebnisse', 'wp-lit-mcp')); + $labels = ['ok' => __('erfolgreich', 'wp-lit-mcp'), 'partial' => __('teilweise', 'wp-lit-mcp')]; foreach (self::RESULTS as $result) { printf( - '', + '', esc_attr($result), - selected($extra['result'], $result, false) + selected($extra['result'], $result, false), + esc_html($labels[$result]) ); } - echo ' '; + echo ''; + self::filterLabel('date-from', __('Von Datum', 'wp-lit-mcp')); printf( - ' ', - esc_html__('von', 'wp-lit-mcp'), + '', esc_attr($extra['date_from']) ); + self::filterLabel('date-to', __('Bis Datum', 'wp-lit-mcp')); printf( - ' ', - esc_html__('bis', 'wp-lit-mcp'), + '', esc_attr($extra['date_to']) ); - submit_button(__('Filtern', 'wp-lit-mcp'), 'secondary', '', false); - echo '
    '; + submit_button(__('Filtern', 'wp-lit-mcp'), '', 'filter_action', false, ['id' => 'lit-mcp-filter-submit']); + } + + private static function filterLabel(string $field, string $text): void + { + printf( + '', + esc_attr($field), + esc_html($text) + ); } private function renderClearForm(): void diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php index 39cf16b..c3aac12 100644 --- a/src/Admin/SettingsPage.php +++ b/src/Admin/SettingsPage.php @@ -11,15 +11,24 @@ final class SettingsPage { public const SLUG = 'lit-mcp'; + /** Hook suffix WordPress gives this page (add_options_page → "settings_page_"). */ + public const SCREEN_HOOK = 'settings_page_' . self::SLUG; + /** @var array */ private array $tabs = []; /** * @param list $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 */ public function tabSlugs(): array { @@ -81,16 +114,19 @@ final class SettingsPage } $current = $this->currentTab(); - echo '
    '; + echo '
    '; + echo '

    ' . esc_html__('LIT MCP', 'wp-lit-mcp') . '

    '; $this->renderLogo(); - echo '

    ' . esc_html__('LIT MCP', 'wp-lit-mcp') . '

    '; + // Admin notices of other plugins are moved right after this marker – above the tabs. + echo '

    '; $this->renderNotice(); - echo '
    '; } 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( - '%s
    %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 ' ' . esc_html($note) . ''; + echo '

    ' . esc_html($note) . '

    '; } echo ''; } + /** "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( + '' + . ' %2$s', + 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'), }; } } diff --git a/src/Plugin.php b/src/Plugin.php index 1d8a7ba..40b6b09 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -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(); diff --git a/tests/Stubs/WpListTable.php b/tests/Stubs/WpListTable.php index 773aa13..3509108 100644 --- a/tests/Stubs/WpListTable.php +++ b/tests/Stubs/WpListTable.php @@ -83,7 +83,14 @@ if (! class_exists('WP_List_Table')) { { } + /** Like the real class: the top table nav (with extra_tablenav()) comes before the rows. */ public function display(): void + { + $this->extra_tablenav('top'); + } + + /** @param string $which */ + protected function extra_tablenav($which) { } } diff --git a/tests/Unit/Admin/AccessTabTest.php b/tests/Unit/Admin/AccessTabTest.php index e2e3abc..8a64fee 100644 --- a/tests/Unit/Admin/AccessTabTest.php +++ b/tests/Unit/Admin/AccessTabTest.php @@ -171,21 +171,36 @@ final class AccessTabTest extends TestCase (new AccessTab(new Access(), $connections))->handleRevoke(); } - public function testRenderShowsRolesAndConnections(): void + /** @param mixed $roles value of the roles option */ + private function stubRender(mixed $roles): void { - Functions\when('get_option')->justReturn(['editor']); + Functions\when('get_option')->alias(static fn (string $key, $default = false) => match ($key) { + 'lit_mcp_allowed_roles' => $roles, + 'date_format' => 'd.m.Y', + 'time_format' => 'H:i', + default => $default, + }); Functions\when('translate_user_role')->returnArg(); Functions\when('checked')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' checked="checked"' : ''); Functions\when('settings_fields')->justReturn(null); - Functions\when('submit_button')->alias(static function (): void { - echo ''; + Functions\when('submit_button')->alias(static function ($text = null, $type = 'primary', $name = 'submit', $wrap = true, $other = ''): void { + $attrs = is_array($other) ? implode(' ', array_map( + static fn ($k, $v): string => sprintf('%s="%s"', $k, $v), + array_keys($other), + $other + )) : ''; + echo ''; }); Functions\when('wp_nonce_field')->alias(static function (string $action): void { echo ''; }); - Functions\when('wp_date')->justReturn('25.09.2026 10:00'); - $connections = Mockery::mock(ConnectionRepository::class); - $connections->allows('all')->andReturn([[ + Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts); + } + + /** @param array $overrides */ + private function connection(array $overrides = []): array + { + return $overrides + [ 'user_id' => 3, 'user_login' => 'redaktion', 'uuid' => 'uuid-1', @@ -193,38 +208,73 @@ final class AccessTabTest extends TestCase 'app_id' => 'https://claude.ai/x', 'created' => 1790000000, 'last_used' => null, - ]]); + ]; + } + + private function renderTab(array $connections): string + { + $repository = Mockery::mock(ConnectionRepository::class); + $repository->allows('all')->andReturn($connections); ob_start(); - (new AccessTab(new Access(), $connections))->render(); - $html = (string) ob_get_clean(); + (new AccessTab(new Access(), $repository))->render(); + + return (string) ob_get_clean(); + } + + public function testRenderShowsRolesAndConnections(): void + { + $this->stubRender(['editor']); + + $html = $this->renderTab([$this->connection()]); self::assertStringContainsString('value="editor" checked="checked"', $html); self::assertStringNotContainsString('value="administrator" checked', $html); - self::assertStringContainsString('Claude <script>', $html); + self::assertStringContainsString('Claude <script>', $html); self::assertStringContainsString('redaktion<b>', $html); self::assertStringNotContainsString('