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
+74 -24
View File
@@ -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 '<button>Speichern</button>';
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 '<button ' . $attrs . '>' . ($text ?? 'Speichern') . '</button>';
});
Functions\when('wp_nonce_field')->alias(static function (string $action): void {
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
});
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<string, mixed> $overrides */
private function connection(array $overrides = []): array
{
return $overrides + [
'user_id' => 3,
'user_login' => 'redaktion<b>',
'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 &lt;script&gt;', $html);
self::assertStringContainsString('<strong>Claude &lt;script&gt;</strong>', $html);
self::assertStringContainsString('redaktion&lt;b&gt;', $html);
self::assertStringNotContainsString('<script>', $html);
self::assertStringNotContainsString('<code>', $html);
self::assertStringContainsString('value="uuid-1"', $html);
self::assertStringContainsString('name="user_id" value="3"', $html);
self::assertStringContainsString('value="lit_mcp_revoke"', $html);
self::assertStringContainsString('nonce-lit_mcp_revoke_uuid-1', $html);
self::assertStringContainsString('confirm(', $html);
}
public function testRenderWithoutConnections(): void
public function testRolesAreAFormTableFieldsetWithLegendAndDescription(): void
{
Functions\when('get_option')->justReturn(['administrator']);
Functions\when('translate_user_role')->returnArg();
Functions\when('checked')->justReturn('');
Functions\when('settings_fields')->justReturn(null);
Functions\when('submit_button')->justReturn(null);
$connections = Mockery::mock(ConnectionRepository::class);
$connections->allows('all')->andReturn([]);
$this->stubRender(['editor']);
ob_start();
(new AccessTab(new Access(), $connections))->render();
$html = (string) ob_get_clean();
$html = $this->renderTab([]);
self::assertStringContainsString('Keine aktiven Verbindungen.', $html);
self::assertStringNotContainsString('<table', $html);
self::assertStringContainsString('<table class="form-table" role="presentation">', $html);
self::assertStringContainsString('<th scope="row">Rollen mit Zugriff</th>', $html);
self::assertStringContainsString('<fieldset><legend class="screen-reader-text"><span>Rollen mit Zugriff</span></legend>', $html);
self::assertStringContainsString('<p class="description">Nur Benutzer dieser Rollen können Claude verbinden.', $html);
}
public function testDatesUseTheSiteDateAndTimeFormat(): void
{
$this->stubRender(['editor']);
$html = $this->renderTab([
$this->connection(['uuid' => 'a', 'last_used' => null]),
$this->connection(['uuid' => 'b', 'last_used' => 1790050000]),
]);
self::assertStringContainsString('d.m.Y H:i@1790000000', $html);
self::assertStringContainsString('d.m.Y@1790050000', $html);
self::assertStringContainsString('<td class="column-last-used">–</td>', $html);
}
public function testRenderWithoutConnectionsShowsAnEmptyStateRowLinkingToTheConnectionTab(): void
{
$this->stubRender(['administrator']);
$html = $this->renderTab([]);
self::assertStringContainsString('<tr class="no-items"><td class="colspanchange" colspan="5">', $html);
self::assertStringContainsString('Noch keine Verbindung.', $html);
self::assertStringContainsString('href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=verbindung"', $html);
}
}