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>
312 lines
12 KiB
PHP
312 lines
12 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', 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>';
|
||
}
|
||
}
|