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:
@@ -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