fix: Admin-UX aus dem Review – Live-Gruppenfeedback, Protokollfilter, Divi-Hinweis, Rollen-Rückfrage

- Tools: „Ganze Gruppe“ und „Nur lesen“ wirken sofort auf die Zeilen
  (Option „wie Gruppe (…)“, gesperrte Selects mit Hinweis), der Server
  bleibt die Wahrheit; Markup trägt die nötigen data-Attribute.
- Protokoll: Filter in eigenem Block über der Tabelle (Core blendet
  .tablenav .actions auf Telefonen aus); „Protokoll leeren“ nur bei
  Einträgen, rechtsbündig, mit Gesamtzahl über ChangeLog::count().
- Divi: Beschreibung unter „Prüfung starten“, Button primär solange
  ungetestet ohne frischen Bericht.
- Zugriff: Rückfrage vor dem Speichern ohne Rolle oder ohne eigene Rolle.
- Verbindung: HTTPS-Hinweis nicht doppelt neben der Statusliste.
- Mobil: breitere Einstellungs-Spalte im Tools-Reiter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-08 16:39:44 +02:00
co-authored by Claude Fable 5.1
parent cc72f9b946
commit 1b73f0652a
17 changed files with 462 additions and 150 deletions
+47 -16
View File
@@ -68,29 +68,33 @@ final class AccessTab implements AdminTab
)));
if ($roles === []) {
self::warnOnce(
'lit_mcp_no_roles',
__('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp')
);
self::warnOnce('lit_mcp_no_roles', self::noRolesWarning());
} elseif (self::currentUserLosesAccess($roles)) {
self::warnOnce(
'lit_mcp_own_role',
__(
'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.',
'wp-lit-mcp'
)
);
self::warnOnce('lit_mcp_own_role', self::ownRoleWarning());
}
return $roles;
}
private static function noRolesWarning(): string
{
return __('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp');
}
private static function ownRoleWarning(): string
{
return __(
'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.',
'wp-lit-mcp'
);
}
public function render(): void
{
$allowed = $this->access->allowedRoles();
$legend = __('Rollen mit Zugriff', 'wp-lit-mcp');
echo '<form method="post" action="options.php">';
$this->openRolesForm();
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));
@@ -148,14 +152,41 @@ final class AccessTab implements AdminTab
/** @param list<string> $roles */
private static function currentUserLosesAccess(array $roles): bool
{
// Multisite super admins pass every capability check regardless of role.
$own = self::ownRoles();
return $own !== null && array_intersect($own, $roles) === [];
}
/**
* Roles of the current user, or null when they do not matter (multisite super admins
* pass every capability check regardless of role).
*
* @return list<string>|null
*/
private static function ownRoles(): ?array
{
if (is_multisite() && is_super_admin()) {
return false;
return null;
}
$user = wp_get_current_user();
return array_values(array_map('strval', (array) wp_get_current_user()->roles));
}
return array_intersect((array) $user->roles, $roles) === [];
/**
* The form carries the two warnings as data attributes so the admin script can ask
* before a save that leaves nobody, or the admin themselves, with access.
*/
private function openRolesForm(): void
{
$ask = ' ' . __('Trotzdem speichern?', 'wp-lit-mcp');
$own = self::ownRoles();
printf(
'<form method="post" action="options.php" class="lit-mcp-access-form"'
. ' data-confirm-none="%s" data-confirm-own="%s"%s>',
esc_attr(self::noRolesWarning() . $ask),
esc_attr(self::ownRoleWarning() . $ask),
$own === null ? '' : sprintf(' data-own-roles="%s"', esc_attr(implode(' ', $own)))
);
}
private function renderConnections(): void
+12 -4
View File
@@ -21,8 +21,13 @@ final class AdminNotices
add_action('admin_notices', [$this, 'render']);
}
/** @return list<string> */
public function messages(bool $onOwnPage): array
/**
* @param bool $onOwnPage On the plugin's own settings page.
* @param bool $statusShown The tab already lists the connection status (the HTTPS hint would be said twice).
*
* @return list<string>
*/
public function messages(bool $onOwnPage, bool $statusShown = false): array
{
$messages = [];
if (! $this->status->oauthEnabled()) {
@@ -56,7 +61,7 @@ final class AdminNotices
) . ' ' . __('Details unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp');
}
if ($onOwnPage && ! $this->status->https()) {
if ($onOwnPage && ! $statusShown && ! $this->status->https()) {
$messages[] = __(
'LIT MCP: Die Website läuft ohne HTTPS; KI-Assistenten verbinden sich nur mit HTTPS-Websites.',
'wp-lit-mcp'
@@ -74,7 +79,10 @@ final class AdminNotices
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
$onOwnPage = is_object($screen) && $screen->id === 'settings_page_' . SettingsPage::SLUG;
foreach ($this->messages($onOwnPage) as $message) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
$tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
$statusShown = $onOwnPage && ($tab === '' || $tab === ConnectionTab::SLUG);
foreach ($this->messages($onOwnPage, $statusShown) as $message) {
printf('<div class="notice notice-warning"><p>%s</p></div>', esc_html($message));
}
}
+3 -1
View File
@@ -8,13 +8,15 @@ defined('ABSPATH') || exit;
final class ConnectionTab implements AdminTab
{
public const SLUG = 'verbindung';
public function __construct(private ConnectionStatus $status)
{
}
public function slug(): string
{
return 'verbindung';
return self::SLUG;
}
public function label(): string
+17 -4
View File
@@ -220,14 +220,27 @@ final class DiviTab implements AdminTab
private function renderForms(ReleaseStatus $status, ?CheckReport $report): void
{
$fresh = $report !== null
&& $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version));
$untested = $status->state === ReleaseState::Untested && $status->version !== '';
printf('<form method="post" action="%s">', esc_url(DiviCheckRoute::url()));
printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
submit_button(__('Prüfung starten', 'wp-lit-mcp'), 'secondary', 'lit-mcp-divi-check', false);
// The check is the one thing to do while untested without a usable report; otherwise the release leads.
submit_button(
__('Prüfung starten', 'wp-lit-mcp'),
$untested && ! $fresh ? 'primary' : 'secondary',
'lit-mcp-divi-check',
false
);
echo '<p class="description">' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Die Prüfung legt eine private Testseite an, prüft Lesen und Schreiben und löscht sie wieder. Sie dauert einige Sekunden.',
'wp-lit-mcp'
) . '</p>';
echo '</form>';
if ($status->state === ReleaseState::Untested && $status->version !== '') {
$fresh = $report !== null
&& $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version));
if ($untested) {
if ($fresh) {
$this->actionForm(
self::RELEASE_ACTION,
+2 -24
View File
@@ -25,9 +25,6 @@ final class LogListTable extends \WP_List_Table
/** Entries younger than a day show a relative time. */
private const RELATIVE_SECONDS = 86400;
/** @var (callable(): void)|null */
private $renderFilters;
private LogObjects $objects;
private int $now;
@@ -36,19 +33,16 @@ final class LogListTable extends \WP_List_Table
* @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
* @param array<string, string> $toolLabels ability name => human label
* @param int|null $now current Unix time (tests), default time()
* @param array<string, string> $toolLabels ability name => human label
* @param int|null $now current Unix time (tests), default time()
*/
public function __construct(
private ChangeLog $log,
private array $filters,
?callable $renderFilters = null,
private array $toolLabels = [],
?LogObjects $objects = null,
?int $now = null
) {
$this->renderFilters = $renderFilters;
$this->objects = $objects ?? new LogObjects();
$this->now = $now ?? time();
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
@@ -109,22 +103,6 @@ final class LogListTable extends \WP_List_Table
);
}
/**
* 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>';
}
/**
* Marks rows whose write did not complete (result "partial": failed after an irreversible step,
* spec §2 principle 7/8.2) or was refused (Divi release lock) so admins notice them while scrolling.
+25 -13
View File
@@ -151,19 +151,19 @@ final class LogTab implements AdminTab
$filters = $this->filters();
$extra = $this->extraFilters();
return new LogListTable($this->log, $filters + $extra, $this->toolLabels());
}
/** @return array<string, string> ability name => label */
private function toolLabels(): array
{
$labels = [];
foreach ($this->registry->abilities() as $ability) {
$labels[$ability->name()] = $ability->label();
}
return new LogListTable(
$this->log,
$filters + $extra,
function () use ($filters, $extra, $labels): void {
$this->renderFilterControls($filters, $extra, $labels);
},
$labels
);
return $labels;
}
public function render(): void
@@ -190,12 +190,20 @@ final class LogTab implements AdminTab
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()));
// Own block, not the table nav: core hides ".tablenav .actions" on phones.
echo '<div class="lit-mcp-log-filters">';
$this->renderFilterControls($this->filters(), $this->extraFilters(), $this->toolLabels());
echo '</div>';
$table = $this->table ?? $this->createTable();
$table->prepare_items();
$table->display();
echo '</form>';
$this->renderClearForm();
// The whole log, not the filtered page: "leeren" removes everything.
$total = $this->log->count();
if ($total > 0) {
$this->renderClearForm($total);
}
}
/**
@@ -306,9 +314,9 @@ final class LogTab implements AdminTab
);
}
private function renderClearForm(): void
private function renderClearForm(int $total): void
{
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
printf('<div class="lit-mcp-clear-log"><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(__(
@@ -316,13 +324,17 @@ final class LogTab implements AdminTab
'wp-lit-mcp'
));
submit_button(
__('Protokoll leeren', 'wp-lit-mcp'),
sprintf(
/* translators: %d: number of log entries */
_n('Protokoll leeren (%d Eintrag)', 'Protokoll leeren (%d Einträge)', $total, 'wp-lit-mcp'),
$total
),
'delete',
'submit',
false,
['onclick' => "return window.confirm('" . $confirmMessage . "');"]
);
echo '</form>';
echo '</form></div>';
}
public function handleClear(): void
+54 -16
View File
@@ -87,7 +87,12 @@ final class ToolsTab implements AdminTab
echo '<form method="post" action="options.php">';
settings_fields(self::GROUP);
submit_button(null, 'primary', 'submit-top');
echo '<div class="lit-mcp-groups">';
// The admin script mirrors a group's mode onto its rows before saving; it needs the label pattern.
printf(
'<div class="lit-mcp-groups" data-like-group="%s">',
/* translators: %s: mode inherited from the group */
esc_attr__('wie Gruppe (%s)', 'wp-lit-mcp')
);
$closed = $this->savedClosedBoxes();
foreach ($groups as $group => $abilities) {
$this->renderGroup($policy, $group, $labels[$group] ?? $group, $abilities, $closed);
@@ -174,7 +179,12 @@ final class ToolsTab implements AdminTab
? $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' : '');
printf(
'<div id="%s" class="postbox lit-mcp-group%s" data-group="%s">',
esc_attr($boxId),
$closed ? ' closed' : '',
esc_attr($group)
);
echo '<div class="postbox-header">';
printf(
'<h2 class="hndle">%s <span class="count">(%s)</span> <span class="lit-mcp-group-summary">%s</span></h2>',
@@ -236,7 +246,8 @@ final class ToolsTab implements AdminTab
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">',
'<span class="lit-mcp-control"><label for="%1$s">%2$s</label>'
. ' <select name="%3$s[%4$s]" class="lit-mcp-group-mode" id="%1$s">',
esc_attr($selectId),
esc_html__('Ganze Gruppe', 'wp-lit-mcp'),
esc_attr(ExposurePolicy::OPTION),
@@ -257,7 +268,8 @@ final class ToolsTab implements AdminTab
}
echo '</select></span>';
printf(
'<span class="lit-mcp-control"><label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label></span>',
'<span class="lit-mcp-control"><label>'
. '<input type="checkbox" class="lit-mcp-group-readonly" 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),
@@ -277,13 +289,16 @@ final class ToolsTab implements AdminTab
$own = $policy->toolOverride($name);
$locked = $policy->disabledByReadonly($ability);
$required = in_array($name, ExposurePolicy::REQUIRED, true);
$writes = ($ability->annotations()['readonly'] ?? false) !== true && ! $required;
$selectId = 'lit-mcp-tool-' . self::idPart($name);
/* translators: %s: default mode */
$defaultLabel = sprintf(__('Standard (%s)', 'wp-lit-mcp'), $this->modeLabel($policy->defaultMode($ability)));
printf(
'<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">',
. '<select name="%6$s[%7$s]" class="lit-mcp-tool-mode" data-default-label="%9$s"%10$s%8$s id="%4$s">',
esc_html($ability->label()),
esc_html($name),
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in kind().
@@ -296,7 +311,10 @@ final class ToolsTab implements AdminTab
)),
esc_attr(ExposurePolicy::OPTION),
esc_attr($name),
$locked ? ' disabled' : ''
$locked ? ' disabled' : '',
esc_attr($defaultLabel),
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed attribute names only.
self::scriptFlags($name, $writes, $required)
);
$inherited = $this->modeLabel($policy->inheritedMode($ability));
printf(
@@ -305,8 +323,7 @@ final class ToolsTab implements AdminTab
esc_html($groupOverride && ! $required
/* translators: %s: mode inherited from the group */
? sprintf(__('wie Gruppe (%s)', 'wp-lit-mcp'), $inherited)
/* translators: %s: default mode */
: sprintf(__('Standard (%s)', 'wp-lit-mcp'), $this->modeLabel($policy->defaultMode($ability))))
: $defaultLabel)
);
foreach (ExposurePolicy::MODES as $mode) {
if (! ExposurePolicy::isAllowed($name, $mode)) {
@@ -331,18 +348,39 @@ final class ToolsTab implements AdminTab
);
}
$note = match (true) {
$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 '<p class="description">' . esc_html($note) . '</p>';
if ($writes) {
// Always in the row so the admin script can show it as soon as "nur lesen" is switched on.
printf(
'<p class="description lit-mcp-readonly-note"%s>%s</p>',
$locked ? '' : ' hidden',
esc_html__('Durch „Nur lesen“ aus', 'wp-lit-mcp')
);
}
if ($own === null && $policy->groupModeAdjusted($ability)) {
echo '<p class="description">'
. esc_html__('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp')
. '</p>';
}
echo '</td></tr>';
}
/** Data attributes telling the admin script how a row reacts to its group's controls. */
private static function scriptFlags(string $name, bool $writes, bool $required): string
{
$flags = '';
if ($writes) {
$flags .= ' data-writes="1"';
}
if ($required) {
$flags .= ' data-required="1"';
}
if (in_array($name, ExposurePolicy::DIRECT_ONLY, true)) {
$flags .= ' data-direct-only="1"';
}
return $flags;
}
/**
* "liest" / "schreibt" / "überschreibt" / "löscht" / "Papierkorb" with a dashicon. The
* "destructive" annotation is a broad hint (deleting, replacing, swapping, ruling S47),