PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Core/Roles.php +534 -99 3.5.04.9.2 View file →
@@ -4,120 +4,555 @@
4 4
5 5 use WPDeveloper\BetterDocs\Utils\Base;
6 6 use WPDeveloper\BetterDocs\Utils\Database;
7 7
8 +/**
9 + * BetterDocs roles and capabilities.
10 + *
11 + * Capabilities are granted on activation ({@see Install::activate()} →
12 + * {@see self::setup()}), which covers the five roles WordPress ships with. Two
13 + * gaps are repaired here:
14 + *
15 + * - a role **created after activation** never receives anything, because
16 + * WordPress has no hook on `add_role()`;
17 + * - a site that was activated silently (by another plugin) never ran
18 + * `setup()` at all.
19 + *
20 + * {@see self::grant_caps_to_role()} is the repair, exposed to support and to
21 + * site owners as an action:
22 + *
23 + * wp eval 'do_action( "betterdocs_grant_caps", "support" );'
24 + *
25 + * On a **Pro-active site** capability assignment belongs to Pro's Settings →
26 + * Roles screen (ADR-033): Pro's `Core\Roles::saved_settings()` grants the
27 + * bundle to every role listed in `article_roles`, and `reconcile_role_caps()`
28 + * adds *and revokes* `edit_docs_settings` / `read_docs_analytics` /
29 + * `read_faq_builder` on every admin load. Granting capabilities directly there
30 + * would be undone on the next `admin_init` and would make the Settings screen
31 + * lie about who holds what, so the repair writes the **setting** instead and
32 + * lets Pro do the granting.
33 + *
34 + * Nothing here ever revokes.
35 + *
36 + * @since 4.9.0 grant_caps_to_role(), repair_new_roles(), bucket_for_role() and
37 + * the `betterdocs_grant_caps` action.
38 + */
8 39 class Roles extends Base {
9 - /**
10 - * Summary of Database
11 - * @var Database
12 - */
13 - public $database;
40 + /**
41 + * Option holding the role slugs this site has already seen.
42 + *
43 + * Seeded (without granting) the first time {@see self::repair_new_roles()}
44 + * runs, so an existing site is never changed retroactively; from then on
45 + * anything absent from it is a role created after activation.
46 + *
47 + * @since 4.9.0
48 + *
49 + * @var string
50 + */
51 + const KNOWN_ROLES_OPTION = 'betterdocs_known_roles';
14 52
15 - public function __construct( Database $database ) {
16 - $this->database = $database;
17 - }
53 + /**
54 + * The capabilities BetterDocs Pro reconciles on every `admin_init`.
55 + *
56 + * Pro's `Core\Roles::reconcile_role_caps()` adds **and revokes** these three
57 + * from `settings_roles` / `analytics_roles` / `faq_roles`, so writing them
58 + * onto a role directly is undone on the next admin page load. The `edit_docs`
59 + * bundle is not reconciled — Pro only grants it, on save — which is what
60 + * makes {@see self::grant_caps_to_role()}'s top-up safe.
61 + *
62 + * @since 4.9.0
63 + *
64 + * @var string[]
65 + */
66 + const PRO_RECONCILED_CAPS = [ 'edit_docs_settings', 'read_docs_analytics', 'read_faq_builder' ];
18 67
19 - /**
20 - * Default Roles Capabilities
21 - *
22 - * @var array
23 - */
24 - public function defaults_capabilities() {
25 - $default_capabilities = [
26 - 'administrator' => [
27 - // post type related caps
28 - 'edit_docs',
29 - 'edit_others_docs',
30 - 'edit_private_docs',
31 - 'edit_published_docs',
32 - 'read_private_docs',
33 - 'publish_docs',
34 - 'delete_docs',
35 - 'delete_private_docs',
36 - 'delete_published_docs',
37 - 'delete_others_docs',
68 + /**
69 + * Summary of Database
70 + * @var Database
71 + */
72 + public $database;
38 73
39 - // doc_terms related caps
40 - 'manage_doc_terms',
41 - 'edit_doc_terms',
42 - 'delete_doc_terms',
74 + public function __construct( Database $database ) {
75 + $this->database = $database;
43 76
44 - // kb terms related caps
45 - 'manage_knowledge_base_terms',
46 - 'edit_knowledge_base_terms',
47 - 'delete_knowledge_base_terms',
77 + $this->assign_admin_capabilities(); //will run when it is called
48 78
49 - // Settings and Analytics Related caps
50 - 'edit_docs_settings',
51 - 'read_docs_analytics'
52 - ],
53 - 'editor' => [
54 - // post type related caps
55 - 'edit_docs',
56 - 'edit_others_docs',
57 - 'edit_private_docs',
58 - 'edit_published_docs',
59 - 'read_private_docs',
60 - 'publish_docs',
61 - 'delete_docs',
62 - 'delete_private_docs',
63 - 'delete_published_docs',
64 - 'delete_others_docs',
79 + /**
80 + * Grant one role its default BetterDocs capabilities.
81 + *
82 + * The supported repair path for a role created after activation, and
83 + * the one line support can hand a site owner:
84 + * `wp eval 'do_action( "betterdocs_grant_caps", "support" );'`
85 + *
86 + * @since 4.9.0
87 + *
88 + * @param string $role Role slug.
89 + */
90 + add_action( 'betterdocs_grant_caps', [ $this, 'grant_caps_to_role' ] );
65 91
66 - // doc_terms related caps
67 - 'manage_doc_terms',
68 - 'edit_doc_terms',
69 - 'delete_doc_terms',
92 + add_action( 'admin_init', [ $this, 'repair_new_roles' ] );
93 + }
70 94
71 - // kb terms related caps
72 - 'manage_knowledge_base_terms',
73 - 'edit_knowledge_base_terms',
74 - 'delete_knowledge_base_terms'
75 - ],
76 - 'author' => [
77 - 'edit_docs',
78 - 'edit_published_docs',
79 - 'publish_docs',
80 - 'delete_docs',
81 - 'delete_published_docs'
82 - ],
83 - 'contributor' => [
84 - 'edit_docs',
85 - 'delete_docs'
86 - ],
87 - 'other' => [
88 - // post type related caps
89 - 'edit_docs',
90 - 'delete_docs'
91 - ]
92 - ];
95 + /**
96 + * Ensure the administrator role always has every BetterDocs capability.
97 + *
98 + * Capabilities are normally granted on activation via Install::activate() ->
99 + * setup(). But when BetterDocs is installed/activated silently by another
100 + * plugin (e.g. Essential Addons from its Integrations screen, or BetterDocs
101 + * Pro), the activation hook does not fire, so setup() never runs and the
102 + * administrator never receives caps like edit_docs, edit_docs_settings or
103 + * read_docs_analytics. That hides most of the BetterDocs admin menu (leaving
104 + * only Quick Setup, gated by the core `delete_users` cap, and FAQ Builder)
105 + * and makes the pages return "Sorry, you are not allowed to access this page."
106 + *
107 + * This self-heals that on every request: any missing admin cap is added back.
108 + * add_cap() is only called for caps the role lacks, so once healed there are
109 + * no further DB writes.
110 + *
111 + * @return void
112 + */
113 + public function assign_admin_capabilities() {
114 + if ( ! current_user_can( 'administrator' ) ) {
115 + return;
116 + }
93 117
94 - return apply_filters( 'betterdocs_default_caps', $default_capabilities );
95 - }
118 + $capabilities = $this->defaults_capabilities();
119 + $admin_caps = isset( $capabilities['administrator'] ) ? $capabilities['administrator'] : [];
96 120
97 - public function setup( $remove = false ) {
98 - if ( $this->database->get( '_betterdocs_caps_initialized', false ) && ! $remove ) {
99 - return;
100 - }
121 + if ( empty( $admin_caps ) ) {
122 + return;
123 + }
101 124
102 - global $wp_roles;
125 + $administrator = get_role( 'administrator' );
126 + if ( ! $administrator ) {
127 + return;
128 + }
103 129
104 - $capabilities = $this->defaults_capabilities();
130 + foreach ( $admin_caps as $cap ) {
131 + if ( ! $administrator->has_cap( $cap ) ) {
132 + $administrator->add_cap( $cap );
133 + }
134 + }
135 + }
105 136
106 - if ( $remove ) {
107 - unset( $capabilities['administrator'] );
108 - }
137 + /**
138 + * Assign FAQ Builder Capability To The Admin
139 + *
140 + * @deprecated Use assign_admin_capabilities() which grants the full admin
141 + * capability set (including read_faq_builder). Kept for backward
142 + * compatibility with any external callers.
143 + */
144 + public function assgin_faq_builder_capability_to_admin() {
145 + if( current_user_can('administrator') && ! current_user_can('read_faq_builder') ) { // if the current user is admin, and current user does not have faq menu visibility option, then assign the faq menu visibility capability
146 + $current_user_role = get_role('administrator');
147 + $current_user_role->add_cap('read_faq_builder');
148 + }
149 + }
109 150
110 - foreach ( $capabilities as $role => $caps ) {
111 - foreach ( $caps as $cap ) {
112 - if ( $remove ) {
113 - $wp_roles->remove_cap( $role, $cap );
114 - continue;
115 - }
151 + /**
152 + * Default Roles Capabilities
153 + *
154 + * @var array
155 + */
156 + public function defaults_capabilities() {
157 + $default_capabilities = [
158 + 'administrator' => [
159 + // post type related caps
160 + 'edit_docs',
161 + 'edit_others_docs',
162 + 'edit_private_docs',
163 + 'edit_published_docs',
164 + 'read_private_docs',
165 + 'publish_docs',
166 + 'delete_docs',
167 + 'delete_private_docs',
168 + 'delete_published_docs',
169 + 'delete_others_docs',
116 170
117 - $wp_roles->add_cap( $role, $cap );
118 - }
119 - }
171 + // doc_terms related caps
172 + 'manage_doc_terms',
173 + 'edit_doc_terms',
174 + 'delete_doc_terms',
120 175
121 - $this->database->get( '_betterdocs_caps_initialized', ! $remove );
122 - }
176 + // kb terms related caps
177 + 'manage_knowledge_base_terms',
178 + 'edit_knowledge_base_terms',
179 + 'delete_knowledge_base_terms',
180 +
181 + // Settings and Analytics Related caps
182 + 'edit_docs_settings',
183 + 'read_docs_analytics',
184 + 'read_faq_builder'
185 + ],
186 + 'editor' => [
187 + // post type related caps
188 + 'edit_docs',
189 + 'edit_others_docs',
190 + 'edit_private_docs',
191 + 'edit_published_docs',
192 + 'read_private_docs',
193 + 'publish_docs',
194 + 'delete_docs',
195 + 'delete_private_docs',
196 + 'delete_published_docs',
197 + 'delete_others_docs',
198 +
199 + // doc_terms related caps
200 + 'manage_doc_terms',
201 + 'edit_doc_terms',
202 + 'delete_doc_terms',
203 +
204 + // kb terms related caps
205 + 'manage_knowledge_base_terms',
206 + 'edit_knowledge_base_terms',
207 + 'delete_knowledge_base_terms'
208 + ],
209 + 'author' => [
210 + 'edit_docs',
211 + 'edit_published_docs',
212 + 'publish_docs',
213 + 'delete_docs',
214 + 'delete_published_docs'
215 + ],
216 + 'contributor' => [
217 + 'edit_docs',
218 + 'delete_docs'
219 + ],
220 + 'other' => [
221 + // post type related caps
222 + 'edit_docs',
223 + 'delete_docs'
224 + ]
225 + ];
226 +
227 + return apply_filters( 'betterdocs_default_caps', $default_capabilities );
228 + }
229 +
230 + /**
231 + * The capability bucket a role slug belongs to.
232 + *
233 + * The five buckets are the keys of {@see self::defaults_capabilities()}
234 + * (`administrator`, `editor`, `author`, `contributor`, `other`). A slug that
235 + * is not itself a bucket — every role a site or another plugin created —
236 + * falls to `other`, which is the deliberately small "can write and delete
237 + * their own docs" set.
238 + *
239 + * Reads the filtered map, so a site that added a bucket through
240 + * `betterdocs_default_caps` gets its own bucket back rather than `other`.
241 + *
242 + * @since 4.9.0
243 + *
244 + * @param string $role Role slug.
245 + * @return string Bucket key.
246 + */
247 + public function bucket_for_role( $role ) {
248 + $role = sanitize_key( (string) $role );
249 + $capabilities = $this->defaults_capabilities();
250 +
251 + if ( '' !== $role && isset( $capabilities[ $role ] ) && 'other' !== $role ) {
252 + return $role;
253 + }
254 +
255 + return 'other';
256 + }
257 +
258 + /**
259 + * Grant one role the capabilities of its bucket. Idempotent, never revokes.
260 + *
261 + * Two paths, because two different things own capability assignment:
262 + *
263 + * - **Free-only site** — adds the bucket's capabilities the role does not
264 + * already hold, straight onto the role (`WP_Roles::add_cap()`).
265 + * - **Pro active** — adds the role to the `article_roles` setting (and to
266 + * `faq_roles` when the bucket is `editor`) through
267 + * `Settings::save_settings()`, which fires `betterdocs::settings::saved`
268 + * and lets Pro's `Core\Roles::saved_settings()` do the granting. Writing
269 + * the capabilities here instead would be reverted by Pro's
270 + * `reconcile_role_caps()` on the next `admin_init`, and Settings → Roles
271 + * would show a role holding nothing it lists. `settings_roles` and
272 + * `analytics_roles` are never touched: `edit_docs_settings` and
273 + * `read_docs_analytics` are the two capabilities a site owner most likely
274 + * meant to withhold. See ADR-033.
275 + *
276 + * Both paths do nothing at all when there is nothing to add, so the double
277 + * registration on a Pro site (Free's `Roles` and Pro's subclass are separate
278 + * container entries, and both constructors hook this action) costs one
279 + * array comparison the second time round.
280 + *
281 + * The Pro path needs `edit_docs_settings` — `Settings::save_settings()`
282 + * refuses otherwise — so run it as an administrator
283 + * (`wp eval --user=1 '…'`). It returns an empty array and logs under
284 + * `WP_DEBUG` when the save is refused.
285 + *
286 + * @since 4.9.0
287 + *
288 + * @param string $role Role slug. Must already exist.
289 + * @param string|null $bucket Bucket to grant; defaults to the role's own.
290 + * @return string[] The capabilities this call added, re-read from the role.
291 + */
292 + public function grant_caps_to_role( $role, $bucket = null ) {
293 + $role = sanitize_key( (string) $role );
294 +
295 + if ( '' === $role || ! function_exists( 'wp_roles' ) ) {
296 + return [];
297 + }
298 +
299 + $roles = wp_roles();
300 +
301 + if ( ! is_object( $roles ) || ! method_exists( $roles, 'is_role' ) || ! $roles->is_role( $role ) ) {
302 + return [];
303 + }
304 +
305 + $bucket = null === $bucket ? $this->bucket_for_role( $role ) : sanitize_key( (string) $bucket );
306 + $capabilities = $this->defaults_capabilities();
307 + $caps = isset( $capabilities[ $bucket ] ) ? (array) $capabilities[ $bucket ] : (array) $capabilities['other'];
308 +
309 + if ( empty( $caps ) ) {
310 + return [];
311 + }
312 +
313 + $before = $this->role_caps( $role );
314 +
315 + if ( $this->pro_owns_capabilities() ) {
316 + $this->add_role_to_pro_settings( $role, $bucket );
317 +
318 + // Pro grants on save, so a role that was *already* listed gets
319 + // nothing from the line above — and a role can be listed while
320 + // holding nothing at all: Pro's `Install::init()` runs
321 + // `Roles::setup( true )` on the request after every Pro
322 + // (re)activation, which strips the bundle from every non-admin role
323 + // without touching the setting (measured 2026-08-23). Top the
324 + // listed role up here. Only the bundle: the three capabilities Pro
325 + // reconciles from its own settings are never written by hand.
326 + if ( $this->listed_in_pro_settings( $role ) ) {
327 + $held = $this->role_caps( $role );
328 +
329 + foreach ( array_diff( $caps, self::PRO_RECONCILED_CAPS ) as $cap ) {
330 + if ( ! in_array( $cap, $held, true ) ) {
331 + $roles->add_cap( $role, $cap );
332 + }
333 + }
334 + }
335 + } else {
336 + foreach ( $caps as $cap ) {
337 + if ( ! in_array( $cap, $before, true ) ) {
338 + $roles->add_cap( $role, $cap );
339 + }
340 + }
341 + }
342 +
343 + return array_values( array_intersect( $caps, array_diff( $this->role_caps( $role ), $before ) ) );
344 + }
345 +
346 + /**
347 + * Grant the default bucket to every role this site has not seen before.
348 + *
349 + * Runs on `admin_init`. **Free-only sites only** — on a Pro site Settings →
350 + * Roles is the source of truth and a new role is simply one more box to
351 + * tick, so this returns immediately rather than quietly widening a list the
352 + * site owner curates (ADR-033).
353 + *
354 + * The first run **seeds and grants nothing**: an existing site's roles were
355 + * left as they are on purpose, and retroactively granting `edit_docs` to
356 + * every custom role on upgrade would be a surprise. Only roles that appear
357 + * after that first sweep are repaired.
358 + *
359 + * @since 4.9.0
360 + *
361 + * @return array<string,string[]> Role slug => capabilities granted.
362 + */
363 + public function repair_new_roles() {
364 + if ( ! function_exists( 'wp_roles' ) || ! function_exists( 'betterdocs' ) ) {
365 + return [];
366 + }
367 +
368 + if ( betterdocs()->is_pro_active() ) {
369 + return [];
370 + }
371 +
372 + $roles = wp_roles();
373 +
374 + if ( ! is_object( $roles ) || ! method_exists( $roles, 'get_names' ) ) {
375 + return [];
376 + }
377 +
378 + $current = array_map( 'strval', array_keys( (array) $roles->get_names() ) );
379 + $known = $this->database->get( self::KNOWN_ROLES_OPTION, false );
380 +
381 + // First run: remember what is here, change nothing.
382 + if ( ! is_array( $known ) ) {
383 + $this->database->save( self::KNOWN_ROLES_OPTION, $current );
384 +
385 + return [];
386 + }
387 +
388 + $known = array_map( 'strval', $known );
389 + $granted = [];
390 +
391 + foreach ( array_diff( $current, $known ) as $role ) {
392 + $caps = $this->grant_caps_to_role( $role );
393 +
394 + if ( ! empty( $caps ) ) {
395 + $granted[ $role ] = $caps;
396 + }
397 + }
398 +
399 + if ( array_values( $current ) !== array_values( $known ) ) {
400 + $this->database->save( self::KNOWN_ROLES_OPTION, $current );
401 + }
402 +
403 + return $granted;
404 + }
405 +
406 + /**
407 + * The capabilities a role currently holds, as a flat list of slugs.
408 + *
409 + * Reads `WP_Roles::$roles`, **not** `get_role()`. `WP_Roles::add_cap()`
410 + * writes to `$roles` and to the `wp_user_roles` option but leaves the
411 + * already-built `WP_Role` objects alone, so a `get_role()` taken after a
412 + * grant in the same request still reports the capabilities the role had
413 + * before it — measured on WordPress 7.1. `WP_Role::add_cap()`, which is what
414 + * Pro uses, updates both, so this is correct for either path.
415 + *
416 + * @since 4.9.0
417 + *
418 + * @param string $role Role slug.
419 + * @return string[]
420 + */
421 + protected function role_caps( $role ) {
422 + $roles = function_exists( 'wp_roles' ) ? wp_roles() : null;
423 +
424 + if ( is_object( $roles ) && isset( $roles->roles[ $role ]['capabilities'] ) && is_array( $roles->roles[ $role ]['capabilities'] ) ) {
425 + return array_keys( array_filter( $roles->roles[ $role ]['capabilities'] ) );
426 + }
427 +
428 + $role_object = get_role( $role );
429 +
430 + if ( ! is_object( $role_object ) || ! isset( $role_object->capabilities ) || ! is_array( $role_object->capabilities ) ) {
431 + return [];
432 + }
433 +
434 + return array_keys( array_filter( $role_object->capabilities ) );
435 + }
436 +
437 + /**
438 + * Whether BetterDocs Pro is the one assigning capabilities on this site.
439 + *
440 + * @since 4.9.0
441 + *
442 + * @return bool
443 + */
444 + protected function pro_owns_capabilities() {
445 + return function_exists( 'betterdocs' ) && betterdocs()->is_pro_active();
446 + }
447 +
448 + /**
449 + * Whether Pro's `article_roles` setting lists this role.
450 + *
451 + * @since 4.9.0
452 + *
453 + * @param string $role Role slug.
454 + * @return bool
455 + */
456 + protected function listed_in_pro_settings( $role ) {
457 + $settings = betterdocs()->settings;
458 +
459 + if ( ! is_object( $settings ) || ! method_exists( $settings, 'get' ) ) {
460 + return false;
461 + }
462 +
463 + $listed = (array) $settings->get( 'article_roles', [ 'administrator' ] );
464 +
465 + return in_array( (string) $role, array_map( 'strval', $listed ), true );
466 + }
467 +
468 + /**
469 + * Add a role to Pro's role settings, so Pro grants it the bundle.
470 + *
471 + * Writes nothing when the role is already listed, which is what makes
472 + * {@see self::grant_caps_to_role()} idempotent on a Pro site.
473 + *
474 + * @since 4.9.0
475 + *
476 + * @param string $role Role slug.
477 + * @param string $bucket Bucket being granted.
478 + * @return bool Whether a save was attempted and succeeded.
479 + */
480 + protected function add_role_to_pro_settings( $role, $bucket ) {
481 + $settings = betterdocs()->settings;
482 +
483 + if ( ! is_object( $settings ) || ! method_exists( $settings, 'save_settings' ) ) {
484 + return false;
485 + }
486 +
487 + $payload = [];
488 + $keys = [ 'article_roles' ];
489 +
490 + // `read_faq_builder` is not in the `editor` bucket, so the Free path
491 + // never grants it; Pro keeps FAQ Builder access in its own setting and
492 + // the rig's own baseline lists `editor` there. Only the editor bucket
493 + // asks for it.
494 + if ( 'editor' === $bucket ) {
495 + $keys[] = 'faq_roles';
496 + }
497 +
498 + foreach ( $keys as $key ) {
499 + $listed = (array) $settings->get( $key, [ 'administrator' ] );
500 + $listed = array_values( array_unique( array_map( 'strval', $listed ) ) );
501 +
502 + if ( ! in_array( $role, $listed, true ) ) {
503 + $listed[] = $role;
504 + $payload[ $key ] = $listed;
505 + }
506 + }
507 +
508 + if ( empty( $payload ) ) {
509 + return false;
510 + }
511 +
512 + $saved = $settings->save_settings( $payload );
513 +
514 + if ( is_wp_error( $saved ) ) {
515 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
516 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic.
517 + error_log(
518 + sprintf(
519 + '[BD-MCP] Could not add the "%1$s" role to BetterDocs Pro\'s role settings: %2$s',
520 + $role,
521 + $saved->get_error_message()
522 + )
523 + );
524 + }
525 +
526 + return false;
527 + }
528 +
529 + return (bool) $saved;
530 + }
531 +
532 + public function setup( $remove = false ) {
533 + if ( $this->database->get( '_betterdocs_caps_initialized', false ) && ! $remove ) {
534 + return;
535 + }
536 +
537 + global $wp_roles;
538 +
539 + $capabilities = $this->defaults_capabilities();
540 +
541 + if ( $remove ) {
542 + unset( $capabilities['administrator'] );
543 + }
544 +
545 + foreach ( $capabilities as $role => $caps ) {
546 + foreach ( $caps as $cap ) {
547 + if ( $remove ) {
548 + $wp_roles->remove_cap( $role, $cap );
549 + continue;
550 + }
551 +
552 + $wp_roles->add_cap( $role, $cap );
553 + }
554 + }
555 +
556 + $this->database->save( '_betterdocs_caps_initialized', ! $remove );
557 + }
123 558 }