| @@ -440,8 +440,105 @@ | ||
| 440 | 440 | return $caps; |
| 441 | 441 | } |
| 442 | 442 | |
| 443 | 443 | /** |
| 444 | + * The only role a till user may assign. | |
| 445 | + * | |
| 446 | + * Customer creation from the till always produces this role; changing a | |
| 447 | + * role is not a POS feature, so nothing legitimate needs more. | |
| 448 | + */ | |
| 449 | + private const TILL_ASSIGNABLE_ROLES = array( 'customer' ); | |
| 450 | + | |
| 451 | + /** | |
| 452 | + * The roles a POS actor may hand out, or null when the actor is not fenced. | |
| 453 | + * | |
| 454 | + * Administrators (and so multisite super admins) are not fenced. A shop | |
| 455 | + * manager — any actor with `manage_woocommerce` — follows WooCommerce's own | |
| 456 | + * list for shop managers, `woocommerce_shop_manager_editable_roles` | |
| 457 | + * (customer by default), which WooCommerce enforces only while it is active | |
| 458 | + * and only for the literal `shop_manager` role name; applying it here keeps | |
| 459 | + * that fence up when WooCommerce is deactivated (the roles and their | |
| 460 | + * capabilities persist) and for a cashier who also holds shop manager. | |
| 461 | + * Everyone else with till access gets TILL_ASSIGNABLE_ROLES. | |
| 462 | + * | |
| 463 | + * @param int $actor Acting user ID. | |
| 464 | + * | |
| 465 | + * @return array|null Role names, or null for an unfenced actor. | |
| 466 | + */ | |
| 467 | + private static function assignable_roles( int $actor ): ?array { | |
| 468 | + if ( $actor < 1 || ! user_can( $actor, 'access_woocommerce_pos' ) || user_can( $actor, 'manage_options' ) ) { | |
| 469 | + return null; | |
| 470 | + } | |
| 471 | + $allowed = self::TILL_ASSIGNABLE_ROLES; | |
| 472 | + if ( user_can( $actor, 'manage_woocommerce' ) ) { | |
| 473 | + $allowed = (array) apply_filters( 'woocommerce_shop_manager_editable_roles', self::TILL_ASSIGNABLE_ROLES ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce's own fence list, applied as WooCommerce applies it. | |
| 474 | + } | |
| 475 | + | |
| 476 | + return array_values( array_filter( array_map( 'strval', $allowed ) ) ); | |
| 477 | + } | |
| 478 | + | |
| 479 | + /** | |
| 480 | + * Fence the roles a till user may assign (`editable_roles` filter). | |
| 481 | + * | |
| 482 | + * The Cashier role holds `edit_users` and, on WooCommerce below 9.9, | |
| 483 | + * `promote_users` (customer creation needed it). WordPress's role-update | |
| 484 | + * checks — `WP_REST_Users_Controller::check_role_update()`, `edit_user()` | |
| 485 | + * and the users.php bulk actions — accept those two capabilities and then | |
| 486 | + * ask `get_editable_roles()` which roles the actor may hand out; nothing | |
| 487 | + * ranked them, so a cashier could set an ordinary customer's role to | |
| 488 | + * Administrator. can_modify() does not catch that: it judges the target's | |
| 489 | + * current capabilities, which a plain customer has none of until after the | |
| 490 | + * update. It only ever removes roles; it never adds one. | |
| 491 | + * | |
| 492 | + * @param array $roles Editable roles keyed by role name. | |
| 493 | + * | |
| 494 | + * @return array | |
| 495 | + */ | |
| 496 | + public static function filter_editable_roles( $roles ): array { | |
| 497 | + $roles = (array) $roles; | |
| 498 | + $allowed = self::assignable_roles( get_current_user_id() ); | |
| 499 | + if ( null === $allowed ) { | |
| 500 | + return $roles; | |
| 501 | + } | |
| 502 | + | |
| 503 | + return array_intersect_key( $roles, array_fill_keys( $allowed, true ) ); | |
| 504 | + } | |
| 505 | + | |
| 506 | + /** | |
| 507 | + * Refuse a multisite "add existing user" invite outside the fence (`invite_user` action). | |
| 508 | + * | |
| 509 | + * WordPress's wp-admin/user-new.php stores the requested role in the `new_user_<key>` | |
| 510 | + * option and only reads `get_editable_roles()` for the email's label, so an | |
| 511 | + * invite to an existing network account can carry any role; accepting it | |
| 512 | + * calls `add_user_to_blog()` with that role unchecked. Same fence as | |
| 513 | + * filter_editable_roles(): a fenced actor's invite may name only an | |
| 514 | + * assignable role, or the invite is deleted before its email goes out. | |
| 515 | + * | |
| 516 | + * @param int $user_id Invited user ID. | |
| 517 | + * @param array|null $role Role label array, null when the role was not editable. | |
| 518 | + * @param string $newuser_key Invitation key. | |
| 519 | + * | |
| 520 | + * @return void | |
| 521 | + */ | |
| 522 | + public static function refuse_unfenced_invite( $user_id, $role, $newuser_key ): void { | |
| 523 | + $allowed = self::assignable_roles( get_current_user_id() ); | |
| 524 | + if ( null === $allowed ) { | |
| 525 | + return; | |
| 526 | + } | |
| 527 | + $invite = get_option( 'new_user_' . $newuser_key ); | |
| 528 | + $requested = \is_array( $invite ) && isset( $invite['role'] ) ? (string) $invite['role'] : ''; | |
| 529 | + if ( \in_array( $requested, $allowed, true ) ) { | |
| 530 | + return; | |
| 531 | + } | |
| 532 | + delete_option( 'new_user_' . $newuser_key ); | |
| 533 | + wp_die( | |
| 534 | + esc_html__( 'Sorry, you are not allowed to give users that role.', 'woocommerce-pos' ), | |
| 535 | + '', | |
| 536 | + array( 'response' => 403 ) | |
| 537 | + ); | |
| 538 | + } | |
| 539 | + | |
| 540 | + /** | |
| 444 | 541 | * Build the staff account permission error. |
| 445 | 542 | * |
| 446 | 543 | * @return \WP_Error |
| 447 | 544 | */ |