Files
WP-LIT-MCP/src/Admin/AccessTab.php
T
Jan-Niclas LoosenandClaude Sonnet 5.5 96fa58188b feat(admin): Einstellungsseite überarbeitet, auch für das Handy
Verbindung: Probleme vor der Anleitung. Tools: Suche, Filter, Alle ein-/ausklappen, Zurücksetzen je Gruppe.
Divi-Reiter in Klartext mit Statussymbolen. Rollenhinweise, Hilfe-Tab, Reiter als Raster und Karten auf dem Handy,
Kontrast, Tippflächen, einheitliche Du-Anrede. Bestätigungen über data-lit-mcp-confirm statt Inline-onclick.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 16:36:16 +01:00

312 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\Access;
defined('ABSPATH') || exit;
/**
* Tab "Zugriff": roles allowed to use LIT MCP and the active OAuth connections
* with revocation (spec §8.4).
*/
final class AccessTab implements AdminTab
{
public const GROUP = 'lit_mcp_access';
public const REVOKE_ACTION = 'lit_mcp_revoke';
private AdminPostAction $action;
public function __construct(
private Access $access,
private ConnectionRepository $connections,
?callable $terminate = null
) {
$this->action = new AdminPostAction($terminate);
}
public function slug(): string
{
return 'zugriff';
}
public function label(): string
{
return __('Zugriff', 'wp-lit-mcp');
}
public function registerSettings(): void
{
register_setting(self::GROUP, Access::OPTION, [
'type' => 'array',
'sanitize_callback' => [self::class, 'sanitizeRoles'],
'default' => ['administrator'],
]);
}
public function registerActions(): void
{
add_action('admin_post_' . self::REVOKE_ACTION, [$this, 'handleRevoke']);
}
/**
* Keeps only existing role slugs. Runs twice on the very first save (update_option
* falls through to add_option, which sanitizes again), so each warning is added
* only once. The settings page itself needs manage_options, not lit_mcp_use, so
* removing the current user's own role is allowed – but warned about.
*
* @return list<string>
*/
public static function sanitizeRoles(mixed $input): array
{
$existing = array_keys((array) wp_roles()->roles);
$roles = array_values(array_unique(array_intersect(
array_filter(is_array($input) ? $input : [], 'is_string'),
$existing
)));
if ($roles === []) {
self::warnOnce('lit_mcp_no_roles', self::noRolesWarning());
} elseif (self::currentUserLosesAccess($roles)) {
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 deiner Rollen ist mehr freigegeben. Du kannst LIT MCP dann selbst nicht mehr nutzen.',
'wp-lit-mcp'
);
}
public function render(): void
{
$allowed = $this->access->allowedRoles();
$legend = __('Rollen mit Zugriff', 'wp-lit-mcp');
$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));
printf(
'<table class="form-table" role="presentation"><tbody><tr><th scope="row">%1$s</th><td>'
. '<fieldset><legend class="screen-reader-text"><span>%1$s</span></legend>',
esc_html($legend)
);
foreach (wp_roles()->get_names() as $role => $name) {
printf(
'<label><input type="checkbox" name="%1$s[]" value="%2$s"%3$s'
. ' aria-describedby="lit-mcp-role-%2$s"> %4$s</label>'
. ' <span class="description" id="lit-mcp-role-%2$s">(%5$s)</span><br>',
esc_attr(Access::OPTION),
esc_attr((string) $role),
checked(in_array($role, $allowed, true), true, false),
esc_html(translate_user_role((string) $name)),
esc_html(self::roleHint((string) $role))
);
}
echo '</fieldset><p class="description">'
. esc_html__('Nur Benutzer dieser Rollen können einen KI-Assistenten verbinden.', 'wp-lit-mcp') . ' '
. esc_html__('Innerhalb der Tools gelten zusätzlich ihre normalen WordPress-Rechte.', 'wp-lit-mcp')
. '</p></td></tr></tbody></table>';
submit_button();
echo '</form>';
$this->renderConnections();
}
/** What a role may do in WordPress, in one phrase, so the admin sees whom they are letting in. */
private static function roleHint(string $role): string
{
$caps = (array) (((array) wp_roles()->roles)[$role]['capabilities'] ?? []);
return match (true) {
! empty($caps['manage_options']) => __('verwaltet die Website', 'wp-lit-mcp'),
! empty($caps['edit_others_posts']) => __('bearbeitet auch fremde Inhalte', 'wp-lit-mcp'),
! empty($caps['edit_posts']) || ! empty($caps['edit_pages']) => __('bearbeitet Inhalte', 'wp-lit-mcp'),
default => __('keine Bearbeitungsrechte erkannt', 'wp-lit-mcp'),
};
}
public function handleRevoke(): void
{
$this->action->guard();
// phpcs:disable WordPress.Security.NonceVerification.Missing -- verified right below.
$uuid = isset($_POST['uuid']) && is_string($_POST['uuid'])
? sanitize_text_field(wp_unslash($_POST['uuid']))
: '';
$userId = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Missing
check_admin_referer(self::REVOKE_ACTION . '_' . $uuid);
$revoked = $this->connections->revoke($userId, $uuid);
$this->action->redirect($this->slug(), $revoked ? 'revoked' : 'revoke_failed');
}
private static function warnOnce(string $code, string $message): void
{
foreach ((array) get_settings_errors(Access::OPTION) as $error) {
if (is_array($error) && ($error['code'] ?? '') === $code) {
return;
}
}
add_settings_error(Access::OPTION, $code, $message, 'warning');
}
/** @param list<string> $roles */
private static function currentUserLosesAccess(array $roles): bool
{
$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 null;
}
return array_values(array_map('strval', (array) wp_get_current_user()->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
{
echo '<h2>' . esc_html__('Verbindungen', 'wp-lit-mcp') . '</h2>';
$connections = $this->connections->all();
printf(
'<table class="widefat striped lit-mcp-connections"><thead><tr>'
. '<th scope="col" class="column-user">%s</th><th scope="col" class="column-client">%s</th>'
. '<th scope="col" class="column-created">%s</th><th scope="col" class="column-last-used">%s</th>'
. '<th scope="col" class="column-actions"><span class="screen-reader-text">%s</span></th>'
. '</tr></thead><tbody>',
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 === []) {
printf(
'<tr class="no-items"><td class="colspanchange" colspan="5">%s %s <a href="%s">%s</a></td></tr>',
esc_html__('Noch keine Verbindung.', 'wp-lit-mcp'),
esc_html__('So verbindest du einen KI-Assistenten:', 'wp-lit-mcp'),
esc_url(SettingsPage::url('verbindung')),
esc_html__('Tab „Verbindung“', 'wp-lit-mcp')
);
}
foreach ($connections as $connection) {
$this->renderConnectionRow($connection);
}
echo '</tbody></table>';
if ($connections !== []) {
echo '<p class="description">' . esc_html__(
'„Zuletzt genutzt“ wird wie bei Anwendungspasswörtern höchstens einmal am Tag aktualisiert.',
'wp-lit-mcp'
) . '</p>';
}
}
/**
* @param array{user_id: int, user_login: string, uuid: string, name: string,
* app_id: string, created: int, last_used: ?int} $connection
*/
private function renderConnectionRow(array $connection): void
{
$dateFormat = (string) get_option('date_format', 'd.m.Y');
$timeFormat = (string) get_option('time_format', 'H:i');
echo '<tr>';
echo '<td class="column-user">' . esc_html($connection['user_login']) . '</td>';
$created = (string) wp_date($dateFormat . ' ' . $timeFormat, $connection['created']);
// The "Verbunden am" column is hidden on phones; the date then shows under the client instead.
printf(
'<td class="column-client"><strong>%s</strong><br><span class="lit-mcp-muted">%s</span>'
. '<br class="lit-mcp-phone-only"><span class="lit-mcp-muted lit-mcp-phone-only">%s</span></td>',
esc_html($connection['name']),
esc_html($connection['app_id']),
esc_html(sprintf(
/* translators: %s: date and time */
__('Verbunden am %s', 'wp-lit-mcp'),
$created
))
);
printf('<td class="column-created">%s</td>', esc_html($created));
$lastUsed = $connection['last_used'] === null
? __('Noch nie', 'wp-lit-mcp')
: (string) wp_date($dateFormat, $connection['last_used']);
echo '<td class="column-last-used">' . esc_html($lastUsed) . '</td>';
echo '<td class="column-actions">';
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::REVOKE_ACTION));
printf('<input type="hidden" name="user_id" value="%s">', esc_attr((string) $connection['user_id']));
printf('<input type="hidden" name="uuid" value="%s">', esc_attr($connection['uuid']));
wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']);
submit_button(
__('Widerrufen', 'wp-lit-mcp'),
'delete small',
'submit',
false,
[
// One id per row: submit_button() would give every button the id "submit".
'id' => 'lit-mcp-revoke-' . $connection['uuid'],
// Starts with the visible word (WCAG 2.5.3) and says whose connection it is.
'aria-label' => sprintf(
/* translators: 1: client name, 2: user login */
__('Widerrufen: %1$s, %2$s', 'wp-lit-mcp'),
$connection['name'],
$connection['user_login']
),
'data-lit-mcp-confirm' => sprintf(
/* translators: 1: client name, 2: user login */
__(
'Verbindung „%1$s“ von %2$s widerrufen? Der Assistent muss sich danach neu anmelden.',
'wp-lit-mcp'
),
$connection['name'],
$connection['user_login']
),
]
);
echo '</form></td></tr>';
}
}