CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m3s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m24s
CI / Coding Standards & Static Analysis (pull_request) Successful in 8m21s
CI / Build Plugin Zip (pull_request) Skipped
The Book a lesson for a student panel built its picker from the us_student role but vetted the submission with the book_lesson capability. ChildLoginGate and RegistrationLoginGate withhold that capability from accounts that keep the role, so the panel offered every guardian-managed child and every unapproved signup and then refused them — with a message claiming no student had been chosen, and a form cleared of all five fields. Withholding book_lesson stops those accounts registering in their own name. It was never meant to stop the studio acting for them, which is what the panel is for, and for a child is the only route to a lesson besides their guardian. Guard the student role instead, via a new RoleManager::isStudent() shared with every picker and guard on the staff side so the two cannot drift apart again. Group enrolment gets the same predicate: addDirect() and grantAccess() vetted their posted ids not at all, and would enrol an instructor, an administrator, or an account deleted since the page was drawn — raising a real payment against them for a priced class. Keep a refused booking's fields as submitted, reading the form through one LessonController::submittedBooking() so what gets booked and what is shown again cannot disagree about a field name. A booking that succeeds still leaves an empty form, so the next one does not inherit it. Closes #185 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01XunBYk2sFEc1oL14sUiuBU
168 lines
5.5 KiB
PHP
168 lines
5.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Auth;
|
|
|
|
class RoleManager {
|
|
|
|
public const STUDIO_ADMIN = 'us_studio_admin';
|
|
public const INSTRUCTOR = 'us_instructor';
|
|
public const STUDENT = 'us_student';
|
|
|
|
public const CAP_MANAGE_AVAILABILITY = 'manage_availability';
|
|
public const CAP_VIEW_LESSONS = 'view_own_lessons';
|
|
public const CAP_BOOK_LESSON = 'book_lesson';
|
|
|
|
public const CAP_MANAGE_INSTRUCTORS = 'manage_instructors';
|
|
public const CAP_MANAGE_STUDENTS = 'manage_students';
|
|
public const CAP_MANAGE_OFFERINGS = 'manage_offerings';
|
|
public const CAP_MANAGE_QUESTIONS = 'manage_questions';
|
|
public const CAP_MANAGE_POLICIES = 'manage_policies';
|
|
public const CAP_MANAGE_BILLING = 'manage_billing';
|
|
public const CAP_VIEW_ALL_LESSONS = 'view_all_lessons';
|
|
public const CAP_VIEW_ALL_PAYMENTS = 'view_all_payments';
|
|
public const CAP_VIEW_OWN_PAYMENTS = 'view_own_payments';
|
|
public const CAP_EXPORT_PAYMENTS = 'export_payments';
|
|
|
|
/**
|
|
* Capabilities granted to the `us_studio_admin` role, and implicitly to any
|
|
* WordPress administrator (see {@see grantStudioCapsToAdministrators()}).
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
public const STUDIO_ADMIN_CAPS = [
|
|
self::CAP_MANAGE_INSTRUCTORS,
|
|
self::CAP_MANAGE_STUDENTS,
|
|
self::CAP_MANAGE_OFFERINGS,
|
|
self::CAP_MANAGE_QUESTIONS,
|
|
self::CAP_MANAGE_POLICIES,
|
|
self::CAP_MANAGE_BILLING,
|
|
self::CAP_VIEW_ALL_LESSONS,
|
|
self::CAP_VIEW_ALL_PAYMENTS,
|
|
self::CAP_EXPORT_PAYMENTS,
|
|
];
|
|
|
|
/**
|
|
* Capabilities granted to the `us_instructor` role, and implicitly to any
|
|
* WordPress administrator (see {@see grantStudioCapsToAdministrators()}) so a
|
|
* single-instructor studio owner can both run the business and teach from one
|
|
* account — managing their own availability and lessons without being assigned
|
|
* a separate `us_instructor` role.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
public const INSTRUCTOR_CAPS = [
|
|
self::CAP_MANAGE_AVAILABILITY,
|
|
self::CAP_MANAGE_OFFERINGS,
|
|
self::CAP_MANAGE_QUESTIONS,
|
|
self::CAP_VIEW_LESSONS,
|
|
self::CAP_VIEW_OWN_PAYMENTS,
|
|
self::CAP_EXPORT_PAYMENTS,
|
|
];
|
|
|
|
/**
|
|
* Whether a user account is a student the studio may act for.
|
|
*
|
|
* Deliberately the role and not the `book_lesson` capability: that capability
|
|
* is withheld from a guardian's child ({@see \Unsupervised\Schedular\Guardian\ChildLoginGate})
|
|
* and from a self-signup still awaiting approval
|
|
* ({@see \Unsupervised\Schedular\Auth\RegistrationLoginGate}), so that neither
|
|
* can book or enrol *in their own name*. Staff booking or enrolling on their
|
|
* behalf is the case those restrictions exist to leave open — and for a child,
|
|
* whose account is never signed in to, it is the only route there is.
|
|
*
|
|
* Use this for every "may the studio register this person?" check, so the
|
|
* pickers staff choose from and the guards that vet their choice cannot drift
|
|
* into offering someone who is then refused.
|
|
*/
|
|
public static function isStudent( int $userId ): bool {
|
|
$user = $userId > 0 ? get_userdata( $userId ) : false;
|
|
|
|
return $user instanceof \WP_User && in_array( self::STUDENT, (array) $user->roles, true );
|
|
}
|
|
|
|
public function __construct( private AccessSettings $access = new AccessSettings() ) {}
|
|
|
|
public function register(): void {
|
|
add_action( 'init', [ $this, 'createRoles' ] );
|
|
add_filter( 'user_has_cap', [ $this, 'grantStudioCapsToAdministrators' ], 10, 1 );
|
|
}
|
|
|
|
public function createRoles(): void {
|
|
if ( get_role( self::STUDIO_ADMIN ) === null ) {
|
|
$studioCaps = [ 'read' => true ];
|
|
foreach ( self::STUDIO_ADMIN_CAPS as $cap ) {
|
|
$studioCaps[ $cap ] = true;
|
|
}
|
|
|
|
add_role(
|
|
self::STUDIO_ADMIN,
|
|
__( 'Studio Admin', 'unsupervised-schedular' ),
|
|
$studioCaps
|
|
);
|
|
}
|
|
|
|
if ( get_role( self::INSTRUCTOR ) === null ) {
|
|
$instructorCaps = [ 'read' => true ];
|
|
foreach ( self::INSTRUCTOR_CAPS as $cap ) {
|
|
$instructorCaps[ $cap ] = true;
|
|
}
|
|
|
|
add_role(
|
|
self::INSTRUCTOR,
|
|
__( 'Instructor', 'unsupervised-schedular' ),
|
|
$instructorCaps
|
|
);
|
|
}
|
|
|
|
if ( get_role( self::STUDENT ) === null ) {
|
|
add_role(
|
|
self::STUDENT,
|
|
__( 'Student', 'unsupervised-schedular' ),
|
|
[
|
|
'read' => true,
|
|
self::CAP_BOOK_LESSON => true,
|
|
self::CAP_VIEW_LESSONS => true,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Grant every studio-admin capability to WordPress administrators.
|
|
*
|
|
* The studio owner runs the site as an administrator (`manage_options`) and
|
|
* should manage offerings, questions, policies, billing, and reports without
|
|
* being assigned the separate `us_studio_admin` role. The instructor
|
|
* capabilities are granted too, so a single-instructor studio owner can manage
|
|
* their own availability and lessons and act as the instructor from the same
|
|
* account. Applied dynamically via the `user_has_cap` filter, so nothing is
|
|
* persisted and the grant disappears when the plugin is deactivated.
|
|
*
|
|
* Both grants are independently toggleable from the Access settings page (see
|
|
* {@see AccessSettings}); they default on, preserving the single-account setup.
|
|
*
|
|
* @param array<string, bool> $allcaps All capabilities currently held by the user.
|
|
* @return array<string, bool>
|
|
*/
|
|
public function grantStudioCapsToAdministrators( array $allcaps ): array {
|
|
if ( empty( $allcaps['manage_options'] ) ) {
|
|
return $allcaps;
|
|
}
|
|
|
|
if ( $this->access->adminsAreStudioAdmins() ) {
|
|
foreach ( self::STUDIO_ADMIN_CAPS as $cap ) {
|
|
$allcaps[ $cap ] = true;
|
|
}
|
|
}
|
|
|
|
if ( $this->access->adminsAreInstructors() ) {
|
|
foreach ( self::INSTRUCTOR_CAPS as $cap ) {
|
|
$allcaps[ $cap ] = true;
|
|
}
|
|
}
|
|
|
|
return $allcaps;
|
|
}
|
|
}
|