*/ public static function slugs(): array { return self::SLUGS; } /** * Capability => label. Keyed by capability slug. * * Only for user-facing output (the Role Manager matrix). Calling this * before `init` triggers a textdomain notice — use slugs() when the labels * are not needed. * * @return array */ public static function capabilities(): array { $labels = [ self::ACCESS => __('Access ThinkRank', 'thinkrank'), 'thinkrank_site_identity' => __('Site Identity', 'thinkrank'), 'thinkrank_analytics' => __('Analytics', 'thinkrank'), 'thinkrank_performance' => __('Performance', 'thinkrank'), 'thinkrank_global_seo' => __('Bulk SEO Optimization', 'thinkrank'), 'thinkrank_image_seo' => __('Image SEO', 'thinkrank'), 'thinkrank_schema' => __('Schema Manager', 'thinkrank'), 'thinkrank_social_media' => __('Social Media', 'thinkrank'), 'thinkrank_crawling' => __('Crawling & AI Indexing', 'thinkrank'), 'thinkrank_instant_indexing' => __('Instant Indexing', 'thinkrank'), 'thinkrank_author_archives' => __('Author Archives', 'thinkrank'), 'thinkrank_content_tools' => __('AI Tools', 'thinkrank'), 'thinkrank_ai_insights' => __('AI Insights', 'thinkrank'), 'thinkrank_internal_links' => __('Internal Links', 'thinkrank'), 'thinkrank_external_links' => __('External Links', 'thinkrank'), 'thinkrank_redirections' => __('Redirections', 'thinkrank'), 'thinkrank_broken_links' => __('Broken Links', 'thinkrank'), 'thinkrank_woocommerce' => __('WooCommerce', 'thinkrank'), 'thinkrank_settings' => __('Settings & API Keys', 'thinkrank'), self::MANAGE_ROLES => __('Manage Roles', 'thinkrank'), ]; // SLUGS is the source of truth for which capabilities exist; the map // above only supplies wording. Ordering by SLUGS means a slug added // without a label still appears (labelled by its slug) rather than // silently vanishing from the matrix. $out = []; foreach (self::SLUGS as $slug) { $out[$slug] = $labels[$slug] ?? $slug; } return $out; } /** * Nav section id => required capability. Used by the SPA (localized) and * mirrors the route map below. * * @return array */ public static function section_map(): array { return [ 'site-identity' => 'thinkrank_site_identity', 'analytics' => 'thinkrank_analytics', 'performance' => 'thinkrank_performance', 'global-seo' => 'thinkrank_global_seo', 'image-seo' => 'thinkrank_image_seo', 'schema' => 'thinkrank_schema', 'social-media' => 'thinkrank_social_media', 'crawling-ai-indexing' => 'thinkrank_crawling', 'instant-indexing' => 'thinkrank_instant_indexing', 'author-archives' => 'thinkrank_author_archives', 'ai-insights' => 'thinkrank_ai_insights', 'internal-links' => 'thinkrank_internal_links', 'external-links' => 'thinkrank_external_links', 'redirections' => 'thinkrank_redirections', 'broken-links' => 'thinkrank_broken_links', 'woocommerce' => 'thinkrank_woocommerce', 'integrations' => 'thinkrank_settings', 'role-manager' => self::MANAGE_ROLES, ]; } /** * REST route prefix (first segment after the namespace) => capability. * * @return array */ public static function route_map(): array { return [ 'site-identity' => 'thinkrank_site_identity', 'seo-analytics' => 'thinkrank_analytics', 'analytics' => 'thinkrank_analytics', // Analytics sub-features that register their own Pro route prefixes // (rather than nesting under /analytics/) — gate them with the // Analytics capability, not the base ACCESS fall-through. 'rank-tracker' => 'thinkrank_analytics', 'keywords' => 'thinkrank_analytics', 'email-report' => 'thinkrank_analytics', 'top-content' => 'thinkrank_analytics', 'url-inspection' => 'thinkrank_analytics', 'refresh-radar' => 'thinkrank_analytics', 'seo-score' => 'thinkrank_content_tools', 'content-brief' => 'thinkrank_content_tools', 'pillar-content' => 'thinkrank_content_tools', 'ai' => 'thinkrank_content_tools', // /metadata/ reads a post's stored SEO meta and belongs to the // AI Tools section — gate it with the same capability as the AI // generators above (was unmapped, so it fell back to base ACCESS). 'metadata' => 'thinkrank_content_tools', 'performance' => 'thinkrank_performance', 'global-seo' => 'thinkrank_global_seo', 'global-robot-meta' => 'thinkrank_crawling', 'image-seo' => 'thinkrank_image_seo', 'ai-insights' => 'thinkrank_ai_insights', 'schema' => 'thinkrank_schema', // Custom Schema (Pro) lives in the Schema Manager section but // registers its own /custom-schema/ prefix. 'custom-schema' => 'thinkrank_schema', // Custom Field Mapping (Pro) also lives in the Schema Manager // section and registers its own /field-mapping/ prefix. 'field-mapping' => 'thinkrank_schema', 'social-media' => 'thinkrank_social_media', 'social-platforms' => 'thinkrank_settings', 'sitemap' => 'thinkrank_crawling', // Publisher Sitemaps (Pro) is part of the Crawling & AI Indexing // section but registers its own /publisher-sitemaps/ prefix. 'publisher-sitemaps' => 'thinkrank_crawling', 'llms-txt' => 'thinkrank_crawling', 'instant-indexing' => 'thinkrank_instant_indexing', 'author-archives' => 'thinkrank_author_archives', 'internal-links' => 'thinkrank_internal_links', 'external-links' => 'thinkrank_external_links', 'redirections' => 'thinkrank_redirections', 'broken-links' => 'thinkrank_broken_links', 'woocommerce' => 'thinkrank_woocommerce', // Multi-location (Pro) is managed inside Site Identity › Business Info. 'locations' => 'thinkrank_site_identity', 'integrations' => 'thinkrank_settings', 'settings-management' => 'thinkrank_settings', 'settings' => 'thinkrank_settings', 'role-manager' => self::MANAGE_ROLES, ]; } /** * Settings-management category => the section capability that owns it. * * `/settings-management/category/` is the one cross-section route * in the plugin. Every other prefix belongs to exactly one section, so * resolving a capability from the first path segment is right for them; here * the segment is the same for all thirteen categories and the *category* * names whose data is being touched. * * Mapping the whole prefix to `thinkrank_settings` therefore gave one answer * to a question with thirteen. It was too strict for Analytics, whose tab * persists through this route and 403'd for a role that had been granted * Analytics, and too loose for anyone holding `thinkrank_settings`, who * could read every other section's settings here while the direct section * routes correctly refused them (#573). * * Every key of Settings_Manager::$settings_categories must appear below; * CapabilityManagerTest pins the two together. An unlisted category falls * back to `thinkrank_settings`, which fails closed rather than open. * * @since 2.1.3 * * @return array */ public static function settings_category_map(): array { return [ 'seo_analytics' => 'thinkrank_analytics', 'social_media' => 'thinkrank_social_media', 'sitemap' => 'thinkrank_crawling', 'schema_management' => 'thinkrank_schema', 'performance_monitoring' => 'thinkrank_performance', 'site_identity' => 'thinkrank_site_identity', 'content_analysis' => 'thinkrank_content_tools', 'content_optimization' => 'thinkrank_content_tools', // Plugin-wide configuration with no single owning section. 'core' => 'thinkrank_settings', 'seo' => 'thinkrank_settings', 'ui' => 'thinkrank_settings', 'integrations' => 'thinkrank_settings', 'basic_integrations' => 'thinkrank_settings', ]; } /** * The capability owning a settings-management category. * * @since 2.1.3 * * @param string $category Category key. * @return string */ public static function capability_for_settings_category(string $category): string { return self::settings_category_map()[$category] ?? 'thinkrank_settings'; } /** * Whether the current user has a ThinkRank capability. * * Administrators (`manage_options`) always pass — this is the lock-out * safety net and means the matrix never needs to touch the admin role. * * @param string $capability Capability slug. * @return bool */ public static function current_user_can(string $capability): bool { if (current_user_can('manage_options')) { return true; } return current_user_can($capability); } /** * The capability guarding a REST route, or the base access cap when the * route's prefix isn't specifically mapped. * * @param string $route Full REST route (e.g. /thinkrank/v1/schema/...). * @return string */ public static function capability_for_route(string $route): string { // Settings-management categories resolve by category rather than by // prefix — see settings_category_map() for why this one route differs. if (preg_match('#/thinkrank(?:-pro)?/v1/settings-management/category/([a-zA-Z0-9_-]+)#', $route, $c)) { return self::capability_for_settings_category($c[1]); } if (!preg_match('#/thinkrank(?:-pro)?/v1/([^/]+)#', $route, $m)) { return self::ACCESS; } return self::route_map()[$m[1]] ?? self::ACCESS; } /** * The list of ThinkRank capabilities the given user holds (for localizing * to the SPA). Administrators get the full set. * * @param int $user_id Optional user id (defaults to current user). * @return string[] */ public static function user_capabilities(int $user_id = 0): array { $user = $user_id ? get_userdata($user_id) : wp_get_current_user(); if (!$user || !$user->exists()) { return []; } if (user_can($user, 'manage_options')) { return array_keys(self::capabilities()); } return array_values(array_filter( array_keys(self::capabilities()), static fn($cap) => user_can($user, $cap) )); } /** * Editable roles excluding administrator (which always has everything). * * @return array role slug => display name. */ public static function editable_roles(): array { // get_editable_roles() lives in wp-admin/includes/user.php, which is not // loaded during REST requests — pull it in so this works in any context. if (!function_exists('get_editable_roles')) { require_once ABSPATH . 'wp-admin/includes/user.php'; } $roles = []; foreach (get_editable_roles() as $slug => $role) { if ($slug === 'administrator') { continue; } $roles[$slug] = translate_user_role($role['name']); } return $roles; } /** * The WordPress capability a role needs before a ThinkRank grant does * anything. * * Several endpoints run their own `edit_posts` check on top of the section * gate — the plugin acts on posts, and a Subscriber has no business there. * That check is not wrong; what was wrong is that the Role Manager modelled * only the section gate. Granting an area to a role below this baseline * saved, ticked the box and showed the section, while every request still * failed, with nothing in the UI to explain why (#576). * * @since 2.1.3 */ public const BASELINE_CAPABILITY = 'edit_posts'; /** * Whether a role can actually act on a ThinkRank grant. * * @since 2.1.3 * * @param string $slug Role slug. * @return bool */ public static function role_meets_baseline(string $slug): bool { $role = get_role($slug); return $role instanceof \WP_Role && $role->has_cap(self::BASELINE_CAPABILITY); } /** * The current assignment matrix: role slug => [capability slugs it has]. * * @return array */ public static function get_matrix(): array { $caps = array_keys(self::capabilities()); $matrix = []; foreach (array_keys(self::editable_roles()) as $slug) { $role = get_role($slug); if (!$role) { continue; } $matrix[$slug] = array_values(array_filter($caps, static fn($cap) => $role->has_cap($cap))); } return $matrix; } /** * Persist an assignment matrix (role slug => [capability slugs]). * * The administrator role is never modified. Granting any section cap also * grants the base ACCESS cap so the role can open ThinkRank. * * @param array $matrix role slug => array of capability slugs. * @return void */ public static function save_matrix(array $matrix): void { $all = array_keys(self::capabilities()); $editable = self::editable_roles(); foreach ($editable as $slug => $name) { $role = get_role($slug); if (!$role) { continue; } // Only modify roles explicitly present in this request, so a partial // save cannot silently strip capabilities from other delegated roles. if (!array_key_exists($slug, $matrix)) { continue; } $granted = is_array($matrix[$slug]) ? array_values(array_intersect($all, array_map('sanitize_key', $matrix[$slug]))) : []; // Any granted section cap implies base access. if (!empty(array_diff($granted, [self::ACCESS])) && !in_array(self::ACCESS, $granted, true)) { $granted[] = self::ACCESS; } foreach ($all as $cap) { if (in_array($cap, $granted, true)) { $role->add_cap($cap); } else { $role->remove_cap($cap); } } } } /** * Ensure the administrator role holds every ThinkRank capability. Runs * once per version (and is safe to call on activation). * * The version option alone is not a sufficient guard: uninstall strips the * capabilities from every role but keeps the option unless the user opted * into deleting all data, so a reinstall would short-circuit here and leave * administrators without {@see self::ACCESS} — locking them out of the admin * menu entirely. Verify the capability is actually present before skipping, * so a stranded option self-heals on the next request. * * @return void */ public static function ensure(): void { $admin = get_role('administrator'); if (get_option(self::VERSION_OPTION) === self::VERSION && $admin && $admin->has_cap(self::ACCESS) ) { return; } if ($admin) { foreach (array_keys(self::capabilities()) as $cap) { $admin->add_cap($cap); } } update_option(self::VERSION_OPTION, self::VERSION, false); // add_cap() updates the role, not an already-instantiated WP_User: that // object cached its allcaps when it was first built, which on this request // happened before `init`. Without rebuilding it, current_user_can() keeps // returning false until the next request — long enough for admin_menu to // skip every ThinkRank page and hand the user a "not allowed" screen right // after activation. Rebuild so the grant takes effect immediately. $user = wp_get_current_user(); if ($user instanceof \WP_User && $user->exists()) { $user->get_role_caps(); } } }