Add Instructors admin page (create + per-capability access)
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 48s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / Coding Standards (pull_request) Successful in 1m5s
CI / PHPStan (pull_request) Successful in 1m11s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 48s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / Coding Standards (pull_request) Successful in 1m5s
CI / PHPStan (pull_request) Successful in 1m11s
CI / Build Plugin Zip (pull_request) Has been skipped
Completes the instructor-management half of #9: the studio admin can now create instructor accounts and toggle each instructor's capabilities. - InstructorController (manage_instructors): list instructors, create a us_instructor WP user (emailing a set-password link), and a per-instructor capability detail view. - InstructorCapabilities: pure, unit-tested rules for which managed caps an admin may assign and how a submitted form maps to assignments. Managed caps are manage_offerings, manage_questions, view_own_payments, export_payments; manage_availability and view_own_lessons are core to every instructor. - A studio admin can never grant a capability it does not itself hold: only held caps (checked via current_user_can, so an administrator's dynamic grant counts) are offered, and on creation any managed cap the admin lacks is denied on the new instructor so they never exceed their creator. The role grants the managed caps by default; the page layers per-user overrides. - AdminMenu: register the Instructors page in the people section. - Tests for the capability logic; docs/features/user-roles.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+16
-1
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityController;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||
use Unsupervised\Schedular\Auth\InstructorController;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RegistrationController;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
@@ -39,6 +40,7 @@ class AdminMenu {
|
||||
private RegistrationController $registrationController;
|
||||
private GroupClassController $groupClassController;
|
||||
private StudentController $studentController;
|
||||
private InstructorController $instructorController;
|
||||
private StudioSettings $settings;
|
||||
private AccessSettings $accessSettings;
|
||||
private PaymentController $paymentController;
|
||||
@@ -53,6 +55,7 @@ class AdminMenu {
|
||||
$this->registrationController = new RegistrationController( $invites );
|
||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
|
||||
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver );
|
||||
$this->instructorController = new InstructorController();
|
||||
$this->settings = $settings;
|
||||
$this->accessSettings = new AccessSettings();
|
||||
$this->paymentController = new PaymentController( $payments, $paymentService );
|
||||
@@ -143,6 +146,17 @@ class AdminMenu {
|
||||
36
|
||||
);
|
||||
|
||||
// Studio admin: create instructors and manage their capabilities.
|
||||
add_menu_page(
|
||||
__( 'Instructors', 'unsupervised-schedular' ),
|
||||
__( 'Instructors', 'unsupervised-schedular' ),
|
||||
RoleManager::CAP_MANAGE_INSTRUCTORS,
|
||||
InstructorController::PAGE_SLUG,
|
||||
[ $this->instructorController, 'renderPage' ],
|
||||
'dashicons-businessperson',
|
||||
34.5
|
||||
);
|
||||
|
||||
// Studio admin: browse students and their activity.
|
||||
add_menu_page(
|
||||
__( 'Students', 'unsupervised-schedular' ),
|
||||
@@ -244,7 +258,8 @@ class AdminMenu {
|
||||
}
|
||||
|
||||
private function userSeesPeopleSection(): bool {
|
||||
return current_user_can( RoleManager::CAP_MANAGE_STUDENTS )
|
||||
return current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS )
|
||||
|| current_user_can( RoleManager::CAP_MANAGE_STUDENTS )
|
||||
|| current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS )
|
||||
|| current_user_can( RoleManager::CAP_MANAGE_BILLING );
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
/**
|
||||
* Pure capability logic for the Instructors admin page: which instructor
|
||||
* capabilities a studio admin may toggle per instructor, and how a submitted form
|
||||
* maps onto capability assignments. Kept free of WordPress so it can be unit
|
||||
* tested; {@see InstructorController} applies the result to a `WP_User`.
|
||||
*/
|
||||
class InstructorCapabilities {
|
||||
|
||||
/**
|
||||
* Instructor capabilities a studio admin can switch on or off per instructor.
|
||||
* The `us_instructor` role grants these by default; the remaining instructor
|
||||
* capabilities (`manage_availability`, `view_own_lessons`) are core to being an
|
||||
* instructor and are not managed here.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const MANAGED = [
|
||||
RoleManager::CAP_MANAGE_OFFERINGS,
|
||||
RoleManager::CAP_MANAGE_QUESTIONS,
|
||||
RoleManager::CAP_VIEW_OWN_PAYMENTS,
|
||||
RoleManager::CAP_EXPORT_PAYMENTS,
|
||||
];
|
||||
|
||||
/**
|
||||
* The managed capabilities the acting studio admin is allowed to assign — a
|
||||
* studio admin can never grant a capability it does not itself hold.
|
||||
*
|
||||
* @param array<string, bool> $adminCaps The acting user's capabilities (cap => held).
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function grantable( array $adminCaps ): array {
|
||||
return array_values(
|
||||
array_filter(
|
||||
self::MANAGED,
|
||||
static fn( string $cap ): bool => ! empty( $adminCaps[ $cap ] )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map submitted checkbox capability names onto the assignment to persist. Only
|
||||
* grantable capabilities are considered, so a request can neither grant a
|
||||
* capability the admin lacks nor one outside the managed set. Every grantable
|
||||
* capability is returned (true when submitted, false otherwise) so the caller
|
||||
* can apply each as an explicit per-user grant or denial.
|
||||
*
|
||||
* @param list<string> $submitted Capability names checked in the form.
|
||||
* @param list<string> $grantable Capabilities the admin may assign.
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
public static function resolve( array $submitted, array $grantable ): array {
|
||||
$resolved = [];
|
||||
foreach ( $grantable as $cap ) {
|
||||
$resolved[ $cap ] = in_array( $cap, $submitted, true );
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
/**
|
||||
* Studio-admin **Instructors** page: create instructor accounts and toggle each
|
||||
* instructor's managed capabilities. Gated on `manage_instructors`. A studio
|
||||
* admin can never grant a capability it does not itself hold; the testable rules
|
||||
* for that live in {@see InstructorCapabilities}.
|
||||
*/
|
||||
class InstructorController {
|
||||
|
||||
public const PAGE_SLUG = 'us-instructors';
|
||||
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to manage instructors.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$notice = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_instructor_action' ) ) {
|
||||
$notice = $this->handleFormAction();
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only instructor selector.
|
||||
$instructorId = absint( $_GET['instructor_id'] ?? 0 );
|
||||
$instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false;
|
||||
|
||||
if ( $instructor && in_array( RoleManager::INSTRUCTOR, (array) $instructor->roles, true ) ) {
|
||||
$grantable = $this->grantableCaps();
|
||||
|
||||
$capabilities = [];
|
||||
foreach ( InstructorCapabilities::MANAGED as $cap ) {
|
||||
$capabilities[ $cap ] = [
|
||||
'granted' => user_can( $instructor, $cap ),
|
||||
'grantable' => in_array( $cap, $grantable, true ),
|
||||
];
|
||||
}
|
||||
|
||||
$backUrl = admin_url( 'admin.php?page=' . self::PAGE_SLUG );
|
||||
include USC_PLUGIN_DIR . 'templates/admin/instructor-detail.php';
|
||||
return;
|
||||
}
|
||||
|
||||
$instructors = array_map(
|
||||
static fn( \WP_User $user ): array => [
|
||||
'id' => (int) $user->ID,
|
||||
'name' => $user->display_name,
|
||||
'email' => $user->user_email,
|
||||
'registered' => $user->user_registered,
|
||||
],
|
||||
get_users(
|
||||
[
|
||||
'role' => RoleManager::INSTRUCTOR,
|
||||
'orderby' => 'display_name',
|
||||
'order' => 'ASC',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$pageSlug = self::PAGE_SLUG;
|
||||
include USC_PLUGIN_DIR . 'templates/admin/instructors.php';
|
||||
}
|
||||
|
||||
private function handleFormAction(): string {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
if ( 'create' === $action ) {
|
||||
return $this->createInstructor();
|
||||
}
|
||||
|
||||
if ( 'update_caps' === $action ) {
|
||||
return $this->updateCaps();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function createInstructor(): string {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
|
||||
$name = sanitize_text_field( wp_unslash( $_POST['display_name'] ?? '' ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
if ( ! is_email( $email ) ) {
|
||||
return __( 'Enter a valid email address.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
if ( false !== email_exists( $email ) ) {
|
||||
return __( 'A user with that email already exists.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$userId = wp_insert_user(
|
||||
[
|
||||
'user_login' => $email,
|
||||
'user_email' => $email,
|
||||
'display_name' => '' !== $name ? $name : $email,
|
||||
'user_pass' => wp_generate_password( 24 ),
|
||||
'role' => RoleManager::INSTRUCTOR,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $userId ) ) {
|
||||
return __( 'Could not create the instructor account.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
// Never let a new instructor exceed the creating admin's own capabilities:
|
||||
// the role grants every managed capability, so deny any the admin lacks.
|
||||
$grantable = $this->grantableCaps();
|
||||
$user = new \WP_User( $userId );
|
||||
foreach ( InstructorCapabilities::MANAGED as $cap ) {
|
||||
if ( ! in_array( $cap, $grantable, true ) ) {
|
||||
$user->add_cap( $cap, false );
|
||||
}
|
||||
}
|
||||
|
||||
wp_new_user_notification( $userId, null, 'user' );
|
||||
|
||||
return __( 'Instructor created and emailed a link to set their password.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
private function updateCaps(): string {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$instructorId = absint( $_POST['instructor_id'] ?? 0 );
|
||||
$submitted = array_map( 'sanitize_key', (array) wp_unslash( $_POST['capabilities'] ?? [] ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
$instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false;
|
||||
if ( ! $instructor || ! in_array( RoleManager::INSTRUCTOR, (array) $instructor->roles, true ) ) {
|
||||
return __( 'Instructor not found.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
foreach ( InstructorCapabilities::resolve( $submitted, $this->grantableCaps() ) as $cap => $granted ) {
|
||||
$instructor->add_cap( $cap, $granted );
|
||||
}
|
||||
|
||||
return __( 'Capabilities updated.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Managed capabilities the acting user may assign. Uses `current_user_can()`
|
||||
* so capabilities the administrator holds only via the dynamic studio grant
|
||||
* still count.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function grantableCaps(): array {
|
||||
$caps = [];
|
||||
foreach ( InstructorCapabilities::MANAGED as $cap ) {
|
||||
$caps[ $cap ] = current_user_can( $cap );
|
||||
}
|
||||
|
||||
return InstructorCapabilities::grantable( $caps );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user