capabilities ) as $cap ) {
if ( ! isset( $wanted[ $cap ] ) ) {
$role->remove_cap( $cap );
}
}
foreach ( $wanted as $cap => $grant ) {
$role->add_cap( $cap, $grant );
}
}
update_option( self::OPT_ROLE_VER, self::ROLE_VERSION, true );
}
/** Capabilities of the dedicated user: write content and media for the SEO agent, nothing else. */
public static function role_capabilities(): array {
return [
'read' => true,
'edit_posts' => true,
'edit_others_posts' => true,
'edit_published_posts' => true,
'publish_posts' => true,
'edit_pages' => true,
'edit_others_pages' => true,
'edit_published_pages' => true,
'publish_pages' => true,
'upload_files' => true,
self::CAPABILITY => true,
];
}
// ── Settings card ───────────────────────────────────────────────────────────
/** Rendered at the top of the settings page, outside its options form. */
public function render_card(): void {
$status = get_option( self::OPT_STATUS, [] );
$status = is_array( $status ) ? $status : [];
$notice = isset( $_GET['agentsia_connect'] ) ? sanitize_key( wp_unslash( $_GET['agentsia_connect'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$message = get_transient( 'agentsia_connect_message_' . get_current_user_id() );
$blocking = $this->blocking_reason();
// Site relié AVANT le bouton, par une clé collée à la main : il fonctionne, on propose seulement de passer au bouton.
$manual = empty( $status['connected'] ) && ! empty( Plugin::get_settings()['api_key'] );
?>
guard( 'agentsia_connect_start' );
$blocking = $this->blocking_reason();
if ( $blocking !== null ) {
$this->back( 'error', $blocking );
}
$state = bin2hex( random_bytes( 24 ) );
set_transient( self::STATE_PREFIX . get_current_user_id(), $state, 10 * MINUTE_IN_SECONDS );
$settings = Plugin::get_settings();
$target = add_query_arg(
[
'site' => rawurlencode( home_url( '/' ) ),
'rest' => rawurlencode( get_rest_url() ),
'state' => $state,
'return' => rawurlencode( admin_url( 'admin-post.php?action=agentsia_connect_return' ) ),
'version' => rawurlencode( AGENTSIA_AGENTS_VERSION ),
],
untrailingslashit( (string) $settings['api_base'] ) . '/connect/wordpress'
);
wp_redirect( $target ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- external by design: the Agentsia app.
exit;
}
// ── Step 2: back from Agentsia with a one-time code ────────────────────────
public function handle_return(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Accès refusé.', 'agentsia-agents' ), 403 );
}
$code = isset( $_GET['agentsia_code'] ) ? sanitize_text_field( wp_unslash( $_GET['agentsia_code'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the `state` below is the CSRF check.
$state = isset( $_GET['agentsia_state'] ) ? sanitize_text_field( wp_unslash( $_GET['agentsia_state'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$key = self::STATE_PREFIX . get_current_user_id();
$saved = get_transient( $key );
delete_transient( $key ); // single use, whatever happens next
if ( ! is_string( $saved ) || $saved === '' || $state === '' || ! hash_equals( $saved, $state ) || ! preg_match( '/^[A-Za-z0-9_-]{20,128}$/', $code ) ) {
$this->back( 'error', __( 'Connexion expirée ou invalide : recommencez depuis le bouton « Connecter à Agentsia ».', 'agentsia-agents' ) );
}
$user = $this->dedicated_user();
if ( is_wp_error( $user ) ) {
$this->back( 'error', $user->get_error_message() );
}
if ( ! wp_is_application_passwords_available_for_user( $user ) ) {
$this->back( 'error', __( 'Les mots de passe d\'application sont bloqués pour cet utilisateur (extension de sécurité ?).', 'agentsia-agents' ) );
}
$this->revoke_passwords( $user->ID );
$created = \WP_Application_Passwords::create_new_application_password( $user->ID, [ 'name' => self::APP_NAME ] );
if ( is_wp_error( $created ) ) {
$this->back( 'error', $created->get_error_message() );
}
[ $password ] = $created;
$site_uuid = (string) get_option( self::OPT_SITE_UUID, '' );
if ( $site_uuid === '' ) {
$site_uuid = wp_generate_uuid4();
update_option( self::OPT_SITE_UUID, $site_uuid, false );
}
$nonce = bin2hex( random_bytes( 24 ) );
update_option( self::OPT_NONCE, $nonce, false );
$settings = Plugin::get_settings();
$response = wp_remote_post(
untrailingslashit( (string) $settings['api_base'] ) . '/api/public/wp/connect',
[
'timeout' => 30,
'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json' ],
'body' => wp_json_encode(
[
'code' => $code,
'state' => $state,
'site_url' => home_url( '/' ),
'rest_url' => get_rest_url(),
'site_uuid' => $site_uuid,
'username' => $user->user_login,
'app_password' => $password,
'plugin_nonce' => $nonce,
'plugin_version' => AGENTSIA_AGENTS_VERSION,
// Articles published by the agent are signed by the admin who connected the site, not by « Agentsia ».
'default_author_id' => get_current_user_id(),
]
),
]
);
delete_option( self::OPT_NONCE ); // the proof of possession happened during that request
$status_code = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
if ( is_wp_error( $response ) || $status_code !== 200 || ! is_array( $body ) || empty( $body['api_key'] ) ) {
$this->revoke_passwords( $user->ID ); // nothing must stay usable after a failed connection
$reason = is_wp_error( $response ) ? $response->get_error_message() : ( is_array( $body ) && ! empty( $body['error'] ) ? (string) $body['error'] : sprintf( 'HTTP %d', $status_code ) );
/* translators: %s: reason. */
$this->back( 'error', sprintf( __( 'Agentsia n\'a pas pu finaliser la connexion : %s', 'agentsia-agents' ), $reason ) );
}
$api_key = (string) $body['api_key'];
if ( ! preg_match( '/^agsk_[a-f0-9]{48}$/', $api_key ) ) {
$this->revoke_passwords( $user->ID );
$this->back( 'error', __( 'Réponse inattendue d\'Agentsia.', 'agentsia-agents' ) );
}
$current = Plugin::get_settings();
$current['api_key'] = $api_key;
update_option( Plugin::OPTION_KEY, $current );
update_option(
self::OPT_STATUS,
[
'connected' => true,
'workspace' => isset( $body['workspace'] ) ? sanitize_text_field( (string) $body['workspace'] ) : '',
'connected_at' => time(),
'user_login' => $user->user_login,
],
false
);
$previous = isset( $body['previous_username'] ) && is_string( $body['previous_username'] ) ? sanitize_user( $body['previous_username'] ) : '';
$this->back(
'ok',
$previous !== ''
/* translators: %s: WordPress user login. */
? sprintf( __( 'Site connecté à Agentsia. Le mot de passe d\'application du compte « %1$s » n\'est plus utilisé par Agentsia : vous pouvez le révoquer (Utilisateurs › %1$s › Mots de passe d\'application).', 'agentsia-agents' ), $previous )
: __( 'Site connecté à Agentsia.', 'agentsia-agents' )
);
}
// ── Disconnect ──────────────────────────────────────────────────────────────
public function handle_disconnect(): void {
$this->guard( 'agentsia_disconnect' );
$settings = Plugin::get_settings();
if ( ! empty( $settings['api_key'] ) ) {
// Best effort: Agentsia marks the integration disconnected and revokes the key.
wp_remote_post(
untrailingslashit( (string) $settings['api_base'] ) . '/api/public/wp/disconnect',
[
'timeout' => 10,
'headers' => [ 'Authorization' => 'Bearer ' . $settings['api_key'], 'Content-Type' => 'application/json' ],
'body' => wp_json_encode( [ 'site_uuid' => (string) get_option( self::OPT_SITE_UUID, '' ) ] ),
]
);
}
$user_id = (int) get_option( self::OPT_USER, 0 );
if ( $user_id > 0 ) {
$this->revoke_passwords( $user_id );
}
$settings['api_key'] = '';
update_option( Plugin::OPTION_KEY, $settings );
delete_option( self::OPT_STATUS );
$this->back( 'ok', __( 'Site déconnecté d\'Agentsia : son accès est révoqué.', 'agentsia-agents' ) );
}
// ── Proof of possession ─────────────────────────────────────────────────────
public function register_routes(): void {
register_rest_route(
Seo_Meta::REST_NAMESPACE,
'/environment',
[
'methods' => 'GET',
'callback' => [ $this, 'handle_environment' ],
'permission_callback' => static function () {
return current_user_can( self::CAPABILITY ) || current_user_can( 'manage_options' );
},
]
);
register_rest_route(
Seo_Meta::REST_NAMESPACE,
'/authors',
[
'methods' => 'GET',
'callback' => [ $this, 'handle_authors' ],
'permission_callback' => static function () {
return current_user_can( self::CAPABILITY ) || current_user_can( 'list_users' );
},
]
);
}
/**
* Authors the agent may sign articles with, for the author choices of the Agentsia app: users who can
* publish posts, plus the « Signataire Agentsia » and contributors (they sign what the agent publishes).
* Only id, name and slug: the dedicated user does not get `list_users` (the full directory, e-mails
* included), which WordPress requires for `/wp/v2/users?context=edit`.
*/
public function handle_authors(): \WP_REST_Response {
$base = [
'number' => 200,
'orderby' => 'display_name',
'exclude' => array_filter( [ (int) get_option( self::OPT_USER, 0 ) ] ),
'fields' => [ 'ID', 'display_name', 'user_nicename' ],
];
// `capability` exists since WordPress 5.9; before, the default publishing roles.
$publishers = version_compare( get_bloginfo( 'version' ), '5.9', '>=' )
? $base + [ 'capability' => [ 'publish_posts' ] ]
: $base + [ 'role__in' => [ 'administrator', 'editor', 'author' ] ];
$signers = $base + [ 'role__in' => [ Site_Members::SIGNATAIRE_ROLE, 'contributor' ] ];
$authors = [];
foreach ( array_merge( get_users( $publishers ), get_users( $signers ) ) as $u ) {
$authors[ (int) $u->ID ] = [ 'id' => (int) $u->ID, 'name' => (string) $u->display_name, 'slug' => (string) $u->user_nicename ];
}
uasort(
$authors,
static function ( $a, $b ) {
return strcasecmp( $a['name'], $b['name'] );
}
);
return new \WP_REST_Response( array_values( $authors ), 200 );
}
public function handle_environment(): \WP_REST_Response {
return new \WP_REST_Response(
[
'site_uuid' => (string) get_option( self::OPT_SITE_UUID, '' ),
// Only present while a connection is being finalised: Agentsia checks it matches what it received.
'nonce' => (string) get_option( self::OPT_NONCE, '' ),
'plugin_version' => AGENTSIA_AGENTS_VERSION,
'wp_version' => get_bloginfo( 'version' ),
'user' => wp_get_current_user()->user_login,
],
200
);
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/** @return \WP_User|\WP_Error */
private function dedicated_user() {
if ( ! get_role( self::ROLE ) ) {
add_role( self::ROLE, __( 'Agentsia (agent SEO)', 'agentsia-agents' ), self::role_capabilities() );
update_option( self::OPT_ROLE_VER, self::ROLE_VERSION, true );
}
$user_id = (int) get_option( self::OPT_USER, 0 );
$user = $user_id > 0 ? get_userdata( $user_id ) : false;
if ( $user instanceof \WP_User ) {
// Keep the role exactly as declared (an admin could have widened it).
$user->set_role( self::ROLE );
return $user;
}
$login = 'agentsia-' . strtolower( wp_generate_password( 6, false, false ) );
$user_id = wp_insert_user(
[
'user_login' => $login,
'user_pass' => wp_generate_password( 64, true, true ), // never given to anyone: no interactive login
'display_name' => 'Agentsia',
'nickname' => 'Agentsia',
'role' => self::ROLE,
'description' => __( 'Utilisateur technique de l\'agent SEO Agentsia (contenus et médias seulement). Son accès est révoqué quand vous déconnectez le site dans Réglages › Agentsia Agents.', 'agentsia-agents' ),
]
);
if ( is_wp_error( $user_id ) ) {
return $user_id;
}
update_option( self::OPT_USER, (int) $user_id, false );
return get_userdata( (int) $user_id );
}
private function revoke_passwords( int $user_id ): void {
foreach ( \WP_Application_Passwords::get_user_application_passwords( $user_id ) as $item ) {
if ( ( $item['name'] ?? '' ) === self::APP_NAME ) {
\WP_Application_Passwords::delete_application_password( $user_id, $item['uuid'] );
}
}
}
private function guard( string $action ): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Accès refusé.', 'agentsia-agents' ), 403 );
}
check_admin_referer( $action );
}
/** Back to the settings page with a message (kept per user, shown once). */
private function back( string $kind, string $message ): void {
set_transient( 'agentsia_connect_message_' . get_current_user_id(), $message, MINUTE_IN_SECONDS );
wp_safe_redirect( add_query_arg( 'agentsia_connect', $kind === 'ok' ? 'ok' : 'error', admin_url( 'options-general.php?page=agentsia-agents' ) ) );
exit;
}
}