Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
246 lines
9.1 KiB
PHP
246 lines
9.1 KiB
PHP
<?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',
|
||
__('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();
|
||
$legend = __('Rollen mit Zugriff', 'wp-lit-mcp');
|
||
|
||
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));
|
||
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="%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))
|
||
);
|
||
}
|
||
echo '</fieldset><p class="description">'
|
||
. esc_html__('Nur Benutzer dieser Rollen können Claude 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();
|
||
}
|
||
|
||
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
|
||
{
|
||
// 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__('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 Claude:', '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>';
|
||
printf(
|
||
'<td class="column-client"><strong>%s</strong><br><span class="lit-mcp-muted">%s</span></td>',
|
||
esc_html($connection['name']),
|
||
esc_html($connection['app_id'])
|
||
);
|
||
printf(
|
||
'<td class="column-created">%s</td>',
|
||
esc_html((string) wp_date($dateFormat . ' ' . $timeFormat, $connection['created']))
|
||
);
|
||
$lastUsed = $connection['last_used'] === null
|
||
? '–'
|
||
: (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']);
|
||
$confirm = esc_js(sprintf(
|
||
/* translators: %s: client name */
|
||
__('Verbindung „%s“ widerrufen? Claude muss sich danach neu anmelden.', 'wp-lit-mcp'),
|
||
$connection['name']
|
||
));
|
||
submit_button(
|
||
__('Widerrufen', 'wp-lit-mcp'),
|
||
'delete small',
|
||
'submit',
|
||
false,
|
||
['onclick' => "return window.confirm('" . $confirm . "');"]
|
||
);
|
||
echo '</form></td></tr>';
|
||
}
|
||
}
|