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 */ 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('', esc_attr(Access::OPTION)); printf( ''; submit_button(); echo ''; $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 $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|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( '
', 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 '

' . esc_html__('Verbindungen', 'wp-lit-mcp') . '

'; $connections = $this->connections->all(); printf( '' . '' . '' . '' . '', 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( '', 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 '
%s%s%s%s%s
%s %s %s
'; if ($connections !== []) { echo '

' . esc_html__( '„Zuletzt genutzt“ wird wie bei Anwendungspasswörtern höchstens einmal am Tag aktualisiert.', 'wp-lit-mcp' ) . '

'; } } /** * @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 ''; echo '' . esc_html($connection['user_login']) . ''; $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( '%s
%s' . '
%s', 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('%s', esc_html($created)); $lastUsed = $connection['last_used'] === null ? __('Noch nie', 'wp-lit-mcp') : (string) wp_date($dateFormat, $connection['last_used']); echo '' . esc_html($lastUsed) . ''; echo ''; printf('', esc_url(admin_url('admin-post.php'))); printf('', esc_attr(self::REVOKE_ACTION)); printf('', esc_attr((string) $connection['user_id'])); printf('', 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 '
'; } }