1, self::USE => 1, self::USE_AI => 2, self::LINKS => 3, self::SEO => 3 ]; /** The per-role write permissions shown in Settings → Access (0.98.132). */ const WRITE_CAPS = [ self::LINKS, self::SEO ]; /** Option storing which version of the role mapping has been applied. */ const VERSION_KEY = 'clusterlane_caps_version'; /** Bump when the default mapping changes, to re-apply on existing sites. */ const VERSION = '3'; /** * @return array role slug => capabilities to grant */ public static function role_map(): array { $map = [ 'administrator' => [ self::MANAGE, self::USE, self::USE_AI, self::LINKS, self::SEO ], 'editor' => [ self::USE, self::USE_AI, self::LINKS, self::SEO ], ]; return (array) apply_filters( 'clusterlane_role_capabilities', $map ); } /** * Grant default capabilities. Additive only, and on an upgrade only the * capabilities introduced AFTER $from_version — so a cap an admin took * away on purpose (e.g. use_clusterlane from editors) is never re-added. */ public static function install( int $from_version = 0 ): void { foreach ( self::role_map() as $role_slug => $caps ) { $role = get_role( $role_slug ); if ( ! $role ) continue; foreach ( (array) $caps as $cap ) { if ( isset( self::SINCE[ $cap ] ) && self::SINCE[ $cap ] > $from_version ) { $role->add_cap( $cap ); } } } // v3: the write permissions are new, but until now everyone with // Clusterlane access could add links and write SEO fields. Keep that // for every role that has access (also custom roles an admin gave // use_clusterlane), so nothing changes until an admin unticks a role. if ( $from_version > 0 && $from_version < 3 ) { foreach ( wp_roles()->role_objects as $role ) { if ( $role->has_cap( self::USE ) || $role->has_cap( self::MANAGE ) ) { foreach ( self::WRITE_CAPS as $cap ) $role->add_cap( $cap ); } } } update_option( self::VERSION_KEY, self::VERSION, false ); } /** * Runs on every boot; only touches roles when the mapping version * differs. Never removes caps — so an admin who deliberately took * use_clusterlane away from editors (via a role editor) keeps that choice. */ public static function maybe_install(): void { $installed = get_option( self::VERSION_KEY ); if ( $installed === self::VERSION ) return; self::install( (int) $installed ); } /** Remove both capabilities from every role on the current site. */ public static function remove_all(): void { $roles = wp_roles(); foreach ( $roles->role_objects as $role ) { $role->remove_cap( self::MANAGE ); $role->remove_cap( self::USE ); $role->remove_cap( self::USE_AI ); foreach ( self::WRITE_CAPS as $cap ) $role->remove_cap( $cap ); } delete_option( self::VERSION_KEY ); } /** * Lock-out safety net: anyone with manage_options always has both * Clusterlane caps, even if a role editor stripped them or the mapping * never ran. Disable with add_filter( 'clusterlane_admin_cap_fallback', '__return_false' ). */ public static function register_fallback(): void { add_filter( 'user_has_cap', [ self::class, 'fallback' ], 10, 1 ); } /** * @param array $allcaps * @return array */ public static function fallback( array $allcaps ): array { if ( ! empty( $allcaps['manage_options'] ) && apply_filters( 'clusterlane_admin_cap_fallback', true ) ) { $allcaps[ self::MANAGE ] = true; $allcaps[ self::USE ] = true; $allcaps[ self::USE_AI ] = true; $allcaps[ self::LINKS ] = true; $allcaps[ self::SEO ] = true; } return $allcaps; } /* ------------------------------------------------------------------ Checks — use these everywhere instead of raw current_user_can() ------------------------------------------------------------------ */ public static function can_manage(): bool { return current_user_can( self::MANAGE ); } public static function can_use(): bool { return current_user_can( self::USE ) || current_user_can( self::MANAGE ); } /** May run NEW AI calls (cached results only need can_use()). */ public static function can_use_ai(): bool { return current_user_can( self::USE_AI ) || current_user_can( self::MANAGE ); } /** May add links to article text through Clusterlane (Text Match, Link Suggestions). */ public static function can_add_links(): bool { return self::can_use() && ( current_user_can( self::LINKS ) || current_user_can( self::MANAGE ) ); } /** May write SEO title / meta description through Clusterlane. */ public static function can_write_seo(): bool { return self::can_use() && ( current_user_can( self::SEO ) || current_user_can( self::MANAGE ) ); } /** REST permission for writes that add or remove links: true, or an error that says why. */ public static function rest_links(): bool|\WP_Error { if ( ! self::can_use() ) return false; return self::can_add_links() ? true : new \WP_Error( 'clusterlane_no_link_rights', self::no_right_message( self::LINKS ), [ 'status' => 403 ] ); } /** REST permission for writing SEO fields: true, or an error that says why. */ public static function rest_seo(): bool|\WP_Error { if ( ! self::can_use() ) return false; return self::can_write_seo() ? true : new \WP_Error( 'clusterlane_no_seo_rights', self::no_right_message( self::SEO ), [ 'status' => 403 ] ); } /** Plain words for a role that may look but not write. */ public static function no_right_message( string $cap ): string { return $cap === self::SEO ? __( 'Your role can see SEO suggestions but not write SEO titles or descriptions. Copy a suggestion, or ask an administrator.', 'clusterlane' ) : __( 'Your role can review link suggestions but not add links to articles. Ask an administrator.', 'clusterlane' ); } /** @return array{links:bool, seo:bool, manage:bool} For page scripts (clusterlaneCan()). */ public static function for_js(): array { return [ 'links' => self::can_add_links(), 'seo' => self::can_write_seo(), 'manage' => self::can_manage() ]; } /** * Role-aware "missing API key" message. Both variants start with * "No AI API key" — the JS detects that prefix to decide on UI. * Only managers are pointed to Settings, since editors can't open it. */ public static function no_ai_key_message(): string { // A key is saved but can't be decrypted (AUTH_KEY changed) — say so, since // Settings shows it as stored (0.98.174). Still starts with "No AI API key". $settings = Settings::get_instance(); if ( $settings && $settings->ai_key_unreadable() ) { if ( $settings->ai_source() === 'network' ) { return __( 'No AI API key can be used: the network\'s saved key can no longer be decrypted (the WordPress security keys have changed). Ask the network administrator to enter it again under Network Admin → Clusterlane.', 'clusterlane' ); } return self::can_manage() ? __( 'No AI API key can be used: the saved key can no longer be decrypted (the WordPress security keys in wp-config.php have changed). Enter it again under Settings → AI.', 'clusterlane' ) : __( 'No AI API key can be used: the saved key can no longer be decrypted. Ask an administrator to enter it again in Clusterlane Settings.', 'clusterlane' ); } // Network manages AI and sites can't add their own key. if ( class_exists( NetworkAi::class ) && NetworkAi::enabled() && ! NetworkAi::site_keys_allowed() ) { return __( 'No AI API key configured. Ask the network administrator to add one under Network Admin → Clusterlane.', 'clusterlane' ); } return self::can_manage() ? __( 'No AI API key configured. Add one under Settings → AI.', 'clusterlane' ) : __( 'No AI API key configured. Ask an administrator to add one in Clusterlane Settings.', 'clusterlane' ); } /** * 400 response for AI endpoints when no usable key exists, else null. * Endpoints call this right before the AI call — after their cache lookup — * so saved results stay visible without a working key (0.98.174). */ public static function no_ai_key_response(): ?\WP_REST_Response { $settings = Settings::get_instance() ?? new Settings(); if ( $settings->ai_api_key() !== '' ) return null; return new \WP_REST_Response( [ 'error' => self::no_ai_key_message() ], 400 ); } }