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
+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;
}
}