| @@ -41,14 +41,70 @@ | ||
| 41 | 41 | */ |
| 42 | 42 | public const MANAGE_ROLES = 'thinkrank_manage_roles'; |
| 43 | 43 | |
| 44 | 44 | /** |
| 45 | + * Every ThinkRank capability, in the order the Role Manager lists them. | |
| 46 | + * | |
| 47 | + * Slugs live here rather than as the keys of capabilities() because the two | |
| 48 | + * callers want different things and only one of them can afford a | |
| 49 | + * translation. grant_admin_caps() runs on `user_has_cap`, which core fires | |
| 50 | + * from wp_set_current_user() during wp-settings.php — before `init`, so a | |
| 51 | + * __() there is both wasted (it discards the labels) and illegal, and WP | |
| 52 | + * 6.7+ answers it with a _load_textdomain_just_in_time notice on every | |
| 53 | + * request. Same class of bug as the cron interval labels in #331. | |
| 54 | + * | |
| 55 | + * capabilities() below builds its labels from this list, so a capability | |
| 56 | + * added here cannot go missing from the admin bypass or the Role Manager UI. | |
| 57 | + * | |
| 58 | + * @since 2.2.0 | |
| 59 | + */ | |
| 60 | + private const SLUGS = [ | |
| 61 | + self::ACCESS, | |
| 62 | + 'thinkrank_site_identity', | |
| 63 | + 'thinkrank_analytics', | |
| 64 | + 'thinkrank_performance', | |
| 65 | + 'thinkrank_global_seo', | |
| 66 | + 'thinkrank_image_seo', | |
| 67 | + 'thinkrank_schema', | |
| 68 | + 'thinkrank_social_media', | |
| 69 | + 'thinkrank_crawling', | |
| 70 | + 'thinkrank_instant_indexing', | |
| 71 | + 'thinkrank_author_archives', | |
| 72 | + 'thinkrank_content_tools', | |
| 73 | + 'thinkrank_ai_insights', | |
| 74 | + 'thinkrank_internal_links', | |
| 75 | + 'thinkrank_external_links', | |
| 76 | + 'thinkrank_redirections', | |
| 77 | + 'thinkrank_broken_links', | |
| 78 | + 'thinkrank_woocommerce', | |
| 79 | + 'thinkrank_settings', | |
| 80 | + self::MANAGE_ROLES, | |
| 81 | + ]; | |
| 82 | + | |
| 83 | + /** | |
| 84 | + * Capability slugs, with no translation involved. | |
| 85 | + * | |
| 86 | + * Safe to call at any point in the request, including before `init`. | |
| 87 | + * | |
| 88 | + * @since 2.2.0 | |
| 89 | + * | |
| 90 | + * @return array<int,string> | |
| 91 | + */ | |
| 92 | + public static function slugs(): array { | |
| 93 | + return self::SLUGS; | |
| 94 | + } | |
| 95 | + | |
| 96 | + /** | |
| 45 | 97 | * Capability => label. Keyed by capability slug. |
| 46 | 98 | * |
| 99 | + * Only for user-facing output (the Role Manager matrix). Calling this | |
| 100 | + * before `init` triggers a textdomain notice — use slugs() when the labels | |
| 101 | + * are not needed. | |
| 102 | + * | |
| 47 | 103 | * @return array<string,string> |
| 48 | 104 | */ |
| 49 | 105 | public static function capabilities(): array { |
| 50 | - return [ | |
| 106 | + $labels = [ | |
| 51 | 107 | self::ACCESS => __('Access ThinkRank', 'thinkrank'), |
| 52 | 108 | 'thinkrank_site_identity' => __('Site Identity', 'thinkrank'), |
| 53 | 109 | 'thinkrank_analytics' => __('Analytics', 'thinkrank'), |
| 54 | 110 | 'thinkrank_performance' => __('Performance', 'thinkrank'), |
| @@ -61,8 +117,9 @@ | ||
| 61 | 117 | 'thinkrank_author_archives' => __('Author Archives', 'thinkrank'), |
| 62 | 118 | 'thinkrank_content_tools' => __('AI Tools', 'thinkrank'), |
| 63 | 119 | 'thinkrank_ai_insights' => __('AI Insights', 'thinkrank'), |
| 64 | 120 | 'thinkrank_internal_links' => __('Internal Links', 'thinkrank'), |
| 121 | + 'thinkrank_external_links' => __('External Links', 'thinkrank'), | |
| 65 | 122 | 'thinkrank_redirections' => __('Redirections', 'thinkrank'), |
| 66 | 123 | 'thinkrank_broken_links' => __('Broken Links', 'thinkrank'), |
| 67 | 124 | 'thinkrank_woocommerce' => __('WooCommerce', 'thinkrank'), |
| 68 | 125 | 'thinkrank_settings' => __('Settings & API Keys', 'thinkrank'), |
| @@ -67,8 +124,19 @@ | ||
| 67 | 124 | 'thinkrank_woocommerce' => __('WooCommerce', 'thinkrank'), |
| 68 | 125 | 'thinkrank_settings' => __('Settings & API Keys', 'thinkrank'), |
| 69 | 126 | self::MANAGE_ROLES => __('Manage Roles', 'thinkrank'), |
| 70 | 127 | ]; |
| 128 | + | |
| 129 | + // SLUGS is the source of truth for which capabilities exist; the map | |
| 130 | + // above only supplies wording. Ordering by SLUGS means a slug added | |
| 131 | + // without a label still appears (labelled by its slug) rather than | |
| 132 | + // silently vanishing from the matrix. | |
| 133 | + $out = []; | |
| 134 | + foreach (self::SLUGS as $slug) { | |
| 135 | + $out[$slug] = $labels[$slug] ?? $slug; | |
| 136 | + } | |
| 137 | + | |
| 138 | + return $out; | |
| 71 | 139 | } |
| 72 | 140 | |
| 73 | 141 | /** |
| 74 | 142 | * Nav section id => required capability. Used by the SPA (localized) and |
| @@ -89,8 +157,9 @@ | ||
| 89 | 157 | 'instant-indexing' => 'thinkrank_instant_indexing', |
| 90 | 158 | 'author-archives' => 'thinkrank_author_archives', |
| 91 | 159 | 'ai-insights' => 'thinkrank_ai_insights', |
| 92 | 160 | 'internal-links' => 'thinkrank_internal_links', |
| 161 | + 'external-links' => 'thinkrank_external_links', | |
| 93 | 162 | 'redirections' => 'thinkrank_redirections', |
| 94 | 163 | 'broken-links' => 'thinkrank_broken_links', |
| 95 | 164 | 'woocommerce' => 'thinkrank_woocommerce', |
| 96 | 165 | 'integrations' => 'thinkrank_settings', |
| @@ -129,14 +198,15 @@ | ||
| 129 | 198 | 'global-seo' => 'thinkrank_global_seo', |
| 130 | 199 | 'global-robot-meta' => 'thinkrank_crawling', |
| 131 | 200 | 'image-seo' => 'thinkrank_image_seo', |
| 132 | 201 | 'ai-insights' => 'thinkrank_ai_insights', |
| 133 | - // Brand Visibility is part of the AI Insights section. | |
| 134 | - 'brand-visibility' => 'thinkrank_ai_insights', | |
| 135 | 202 | 'schema' => 'thinkrank_schema', |
| 136 | 203 | // Custom Schema (Pro) lives in the Schema Manager section but |
| 137 | 204 | // registers its own /custom-schema/ prefix. |
| 138 | 205 | 'custom-schema' => 'thinkrank_schema', |
| 206 | + // Custom Field Mapping (Pro) also lives in the Schema Manager | |
| 207 | + // section and registers its own /field-mapping/ prefix. | |
| 208 | + 'field-mapping' => 'thinkrank_schema', | |
| 139 | 209 | 'social-media' => 'thinkrank_social_media', |
| 140 | 210 | 'social-platforms' => 'thinkrank_settings', |
| 141 | 211 | 'sitemap' => 'thinkrank_crawling', |
| 142 | 212 | // Publisher Sitemaps (Pro) is part of the Crawling & AI Indexing |
| @@ -145,8 +215,9 @@ | ||
| 145 | 215 | 'llms-txt' => 'thinkrank_crawling', |
| 146 | 216 | 'instant-indexing' => 'thinkrank_instant_indexing', |
| 147 | 217 | 'author-archives' => 'thinkrank_author_archives', |
| 148 | 218 | 'internal-links' => 'thinkrank_internal_links', |
| 219 | + 'external-links' => 'thinkrank_external_links', | |
| 149 | 220 | 'redirections' => 'thinkrank_redirections', |
| 150 | 221 | 'broken-links' => 'thinkrank_broken_links', |
| 151 | 222 | 'woocommerce' => 'thinkrank_woocommerce', |
| 152 | 223 | // Multi-location (Pro) is managed inside Site Identity › Business Info. |
| @@ -158,8 +229,63 @@ | ||
| 158 | 229 | ]; |
| 159 | 230 | } |
| 160 | 231 | |
| 161 | 232 | /** |
| 233 | + * Settings-management category => the section capability that owns it. | |
| 234 | + * | |
| 235 | + * `/settings-management/category/<category>` is the one cross-section route | |
| 236 | + * in the plugin. Every other prefix belongs to exactly one section, so | |
| 237 | + * resolving a capability from the first path segment is right for them; here | |
| 238 | + * the segment is the same for all thirteen categories and the *category* | |
| 239 | + * names whose data is being touched. | |
| 240 | + * | |
| 241 | + * Mapping the whole prefix to `thinkrank_settings` therefore gave one answer | |
| 242 | + * to a question with thirteen. It was too strict for Analytics, whose tab | |
| 243 | + * persists through this route and 403'd for a role that had been granted | |
| 244 | + * Analytics, and too loose for anyone holding `thinkrank_settings`, who | |
| 245 | + * could read every other section's settings here while the direct section | |
| 246 | + * routes correctly refused them (#573). | |
| 247 | + * | |
| 248 | + * Every key of Settings_Manager::$settings_categories must appear below; | |
| 249 | + * CapabilityManagerTest pins the two together. An unlisted category falls | |
| 250 | + * back to `thinkrank_settings`, which fails closed rather than open. | |
| 251 | + * | |
| 252 | + * @since 2.1.3 | |
| 253 | + * | |
| 254 | + * @return array<string,string> | |
| 255 | + */ | |
| 256 | + public static function settings_category_map(): array { | |
| 257 | + return [ | |
| 258 | + 'seo_analytics' => 'thinkrank_analytics', | |
| 259 | + 'social_media' => 'thinkrank_social_media', | |
| 260 | + 'sitemap' => 'thinkrank_crawling', | |
| 261 | + 'schema_management' => 'thinkrank_schema', | |
| 262 | + 'performance_monitoring' => 'thinkrank_performance', | |
| 263 | + 'site_identity' => 'thinkrank_site_identity', | |
| 264 | + 'content_analysis' => 'thinkrank_content_tools', | |
| 265 | + 'content_optimization' => 'thinkrank_content_tools', | |
| 266 | + // Plugin-wide configuration with no single owning section. | |
| 267 | + 'core' => 'thinkrank_settings', | |
| 268 | + 'seo' => 'thinkrank_settings', | |
| 269 | + 'ui' => 'thinkrank_settings', | |
| 270 | + 'integrations' => 'thinkrank_settings', | |
| 271 | + 'basic_integrations' => 'thinkrank_settings', | |
| 272 | + ]; | |
| 273 | + } | |
| 274 | + | |
| 275 | + /** | |
| 276 | + * The capability owning a settings-management category. | |
| 277 | + * | |
| 278 | + * @since 2.1.3 | |
| 279 | + * | |
| 280 | + * @param string $category Category key. | |
| 281 | + * @return string | |
| 282 | + */ | |
| 283 | + public static function capability_for_settings_category(string $category): string { | |
| 284 | + return self::settings_category_map()[$category] ?? 'thinkrank_settings'; | |
| 285 | + } | |
| 286 | + | |
| 287 | + /** | |
| 162 | 288 | * Whether the current user has a ThinkRank capability. |
| 163 | 289 | * |
| 164 | 290 | * Administrators (`manage_options`) always pass — this is the lock-out |
| 165 | 291 | * safety net and means the matrix never needs to touch the admin role. |
| @@ -181,8 +307,14 @@ | ||
| 181 | 307 | * @param string $route Full REST route (e.g. /thinkrank/v1/schema/...). |
| 182 | 308 | * @return string |
| 183 | 309 | */ |
| 184 | 310 | public static function capability_for_route(string $route): string { |
| 311 | + // Settings-management categories resolve by category rather than by | |
| 312 | + // prefix — see settings_category_map() for why this one route differs. | |
| 313 | + if (preg_match('#/thinkrank(?:-pro)?/v1/settings-management/category/([a-zA-Z0-9_-]+)#', $route, $c)) { | |
| 314 | + return self::capability_for_settings_category($c[1]); | |
| 315 | + } | |
| 316 | + | |
| 185 | 317 | if (!preg_match('#/thinkrank(?:-pro)?/v1/([^/]+)#', $route, $m)) { |
| 186 | 318 | return self::ACCESS; |
| 187 | 319 | } |
| 188 | 320 | return self::route_map()[$m[1]] ?? self::ACCESS; |
| @@ -228,8 +360,37 @@ | ||
| 228 | 360 | } |
| 229 | 361 | $roles[$slug] = translate_user_role($role['name']); |
| 230 | 362 | } |
| 231 | 363 | return $roles; |
| 364 | + } | |
| 365 | + | |
| 366 | + /** | |
| 367 | + * The WordPress capability a role needs before a ThinkRank grant does | |
| 368 | + * anything. | |
| 369 | + * | |
| 370 | + * Several endpoints run their own `edit_posts` check on top of the section | |
| 371 | + * gate — the plugin acts on posts, and a Subscriber has no business there. | |
| 372 | + * That check is not wrong; what was wrong is that the Role Manager modelled | |
| 373 | + * only the section gate. Granting an area to a role below this baseline | |
| 374 | + * saved, ticked the box and showed the section, while every request still | |
| 375 | + * failed, with nothing in the UI to explain why (#576). | |
| 376 | + * | |
| 377 | + * @since 2.1.3 | |
| 378 | + */ | |
| 379 | + public const BASELINE_CAPABILITY = 'edit_posts'; | |
| 380 | + | |
| 381 | + /** | |
| 382 | + * Whether a role can actually act on a ThinkRank grant. | |
| 383 | + * | |
| 384 | + * @since 2.1.3 | |
| 385 | + * | |
| 386 | + * @param string $slug Role slug. | |
| 387 | + * @return bool | |
| 388 | + */ | |
| 389 | + public static function role_meets_baseline(string $slug): bool { | |
| 390 | + $role = get_role($slug); | |
| 391 | + | |
| 392 | + return $role instanceof \WP_Role && $role->has_cap(self::BASELINE_CAPABILITY); | |
| 232 | 393 | } |
| 233 | 394 | |
| 234 | 395 | /** |
| 235 | 396 | * The current assignment matrix: role slug => [capability slugs it has]. |