feat: access tab with allowed roles and connection revocation

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 03:33:54 +02:00
co-authored by Claude Opus 5.5
parent 169d5772b5
commit eee1840411
8 changed files with 749 additions and 1 deletions
+211
View File
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\Access;
/**
* 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';
/** @var callable(): void */
private $terminate;
public function __construct(
private Access $access,
private ConnectionRepository $connections,
?callable $terminate = null
) {
$this->terminate = $terminate ?? static function (): void {
exit;
};
}
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',
__('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp')
);
} 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'
)
);
}
return $roles;
}
public function render(): void
{
$allowed = $this->access->allowedRoles();
echo '<h2>' . esc_html__('Freigegebene Rollen', 'wp-lit-mcp') . '</h2>';
echo '<p>' . esc_html__(
'Nur Benutzer dieser Rollen können Tools nutzen; zusätzlich gelten ihre normalen WordPress-Rechte.',
'wp-lit-mcp'
) . '</p>';
echo '<form method="post" action="options.php">';
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));
foreach (wp_roles()->get_names() as $role => $name) {
printf(
'<label><input type="checkbox" name="%s[]" value="%s"%s> %s</label><br>',
esc_attr(Access::OPTION),
esc_attr((string) $role),
checked(in_array($role, $allowed, true), true, false),
esc_html(translate_user_role((string) $name))
);
}
submit_button();
echo '</form>';
$this->renderConnections();
}
public function handleRevoke(): void
{
if (! current_user_can('manage_options')) {
wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]);
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- verified right below.
$uuid = isset($_POST['uuid']) ? sanitize_text_field(wp_unslash((string) $_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);
$notice = $revoked ? 'revoked' : 'revoke_failed';
wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => $notice]));
($this->terminate)();
}
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
{
// Multisite super admins pass every capability check regardless of role.
if (is_multisite() && is_super_admin()) {
return false;
}
$user = wp_get_current_user();
return array_intersect((array) $user->roles, $roles) === [];
}
private function renderConnections(): void
{
echo '<h2>' . esc_html__('Aktive Verbindungen', 'wp-lit-mcp') . '</h2>';
$connections = $this->connections->all();
if ($connections === []) {
echo '<p>' . esc_html__('Keine aktiven Verbindungen.', 'wp-lit-mcp') . '</p>';
return;
}
echo '<table class="widefat striped"><thead><tr>';
$headings = [
__('Benutzer', 'wp-lit-mcp'),
__('Client', 'wp-lit-mcp'),
__('Erstellt', 'wp-lit-mcp'),
__('Zuletzt genutzt', 'wp-lit-mcp'),
'',
];
foreach ($headings as $heading) {
echo '<th>' . esc_html($heading) . '</th>';
}
echo '</tr></thead><tbody>';
foreach ($connections as $connection) {
$this->renderConnectionRow($connection);
}
echo '</tbody></table>';
}
/**
* @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
{
echo '<tr>';
echo '<td>' . esc_html($connection['user_login']) . '</td>';
printf('<td>%s<br><code>%s</code></td>', esc_html($connection['name']), esc_html($connection['app_id']));
echo '<td>' . esc_html((string) wp_date('Y-m-d H:i', $connection['created'])) . '</td>';
$lastUsed = $connection['last_used'] === null
? '–'
: (string) wp_date('Y-m-d H:i', $connection['last_used']);
echo '<td>' . esc_html($lastUsed) . '</td>';
echo '<td>';
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);
echo '</form></td></tr>';
}
}