- 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>
223 lines
8.0 KiB
PHP
223 lines
8.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Admin;
|
|
|
|
use LIT\MCP\ChangeLog\ChangeLog;
|
|
use LIT\MCP\Limits;
|
|
|
|
/**
|
|
* Tab "Protokoll": filterable list of change log entries, links to revisions, and
|
|
* "Protokoll leeren" (spec §8.3).
|
|
*/
|
|
final class LogTab implements AdminTab
|
|
{
|
|
public const CLEAR_ACTION = 'lit_mcp_clear_log';
|
|
private const RESULTS = ['ok', 'partial'];
|
|
|
|
/** @var callable(): void */
|
|
private $terminate;
|
|
|
|
public function __construct(private ChangeLog $log, private Limits $limits, ?callable $terminate = null)
|
|
{
|
|
$this->terminate = $terminate ?? static function (): void {
|
|
exit;
|
|
};
|
|
}
|
|
|
|
public function slug(): string
|
|
{
|
|
return 'protokoll';
|
|
}
|
|
|
|
public function label(): string
|
|
{
|
|
return __('Protokoll', 'wp-lit-mcp');
|
|
}
|
|
|
|
public function registerSettings(): void
|
|
{
|
|
}
|
|
|
|
public function registerActions(): void
|
|
{
|
|
add_action('admin_post_' . self::CLEAR_ACTION, [$this, 'handleClear']);
|
|
}
|
|
|
|
/** @return array{ability: string, user_id: int} */
|
|
public function filters(): array
|
|
{
|
|
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters.
|
|
$ability = isset($_GET['ability']) ? sanitize_text_field(wp_unslash((string) $_GET['ability'])) : '';
|
|
$userId = isset($_GET['user_id']) ? absint($_GET['user_id']) : 0;
|
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
|
|
|
return [
|
|
'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $ability) === 1 ? $ability : '',
|
|
'user_id' => $userId,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Additional whitelisted filters (ruling C5/Q13: ability, result, user, date range).
|
|
* Kept separate from filters() so that method's return shape stays exactly what is
|
|
* unit-tested (and what earlier callers of this tab may already rely on).
|
|
*
|
|
* @return array{result: string, date_from: string, date_to: string}
|
|
*/
|
|
public function extraFilters(): array
|
|
{
|
|
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters.
|
|
$result = isset($_GET['result']) ? sanitize_text_field(wp_unslash((string) $_GET['result'])) : '';
|
|
$dateFrom = isset($_GET['date_from']) ? sanitize_text_field(wp_unslash((string) $_GET['date_from'])) : '';
|
|
$dateTo = isset($_GET['date_to']) ? sanitize_text_field(wp_unslash((string) $_GET['date_to'])) : '';
|
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
|
|
|
return [
|
|
'result' => in_array($result, self::RESULTS, true) ? $result : '',
|
|
'date_from' => self::isDate($dateFrom) ? $dateFrom : '',
|
|
'date_to' => self::isDate($dateTo) ? $dateTo : '',
|
|
];
|
|
}
|
|
|
|
private static function isDate(string $value): bool
|
|
{
|
|
return preg_match('#^\d{4}-\d{2}-\d{2}$#', $value) === 1;
|
|
}
|
|
|
|
public function render(): void
|
|
{
|
|
if (! class_exists('WP_List_Table')) {
|
|
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
|
|
}
|
|
|
|
$filters = $this->filters();
|
|
$extra = $this->extraFilters();
|
|
$days = $this->limits->logRetentionDays();
|
|
printf(
|
|
'<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
|
|
))
|
|
)
|
|
)
|
|
);
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* @param array{ability: string, user_id: int} $filters
|
|
* @param array{result: string, date_from: string, date_to: string} $extra
|
|
*/
|
|
private function renderFilterControls(array $filters, array $extra): void
|
|
{
|
|
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>',
|
|
esc_attr($ability),
|
|
selected($filters['ability'], $ability, false)
|
|
);
|
|
}
|
|
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'],
|
|
]);
|
|
|
|
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="%s"%s>%s</option>',
|
|
esc_attr($result),
|
|
selected($extra['result'], $result, false),
|
|
esc_html($labels[$result])
|
|
);
|
|
}
|
|
echo '</select>';
|
|
|
|
self::filterLabel('date-from', __('Von Datum', 'wp-lit-mcp'));
|
|
printf(
|
|
'<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(
|
|
'<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'), '', '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
|
|
{
|
|
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
|
|
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::CLEAR_ACTION));
|
|
wp_nonce_field(self::CLEAR_ACTION);
|
|
$confirmMessage = esc_js(__(
|
|
'Das gesamte Protokoll unwiderruflich löschen?',
|
|
'wp-lit-mcp'
|
|
));
|
|
submit_button(
|
|
__('Protokoll leeren', 'wp-lit-mcp'),
|
|
'delete',
|
|
'submit',
|
|
false,
|
|
['onclick' => "return window.confirm('" . $confirmMessage . "');"]
|
|
);
|
|
echo '</form>';
|
|
}
|
|
|
|
public function handleClear(): void
|
|
{
|
|
if (! current_user_can('manage_options')) {
|
|
wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]);
|
|
}
|
|
|
|
check_admin_referer(self::CLEAR_ACTION);
|
|
$this->log->clear();
|
|
wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => 'cleared']));
|
|
($this->terminate)();
|
|
}
|
|
}
|