Merge pull request 'Add Instructors admin page (create + per-capability access)' (#30) from feature/instructor-management into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.3) (push) Successful in 45s
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 50s
CI / Coding Standards (push) Successful in 54s
CI / PHPStan (push) Successful in 1m5s
CI / Build Plugin Zip (push) Successful in 50s

Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
2026-06-08 20:05:33 +00:00
7 changed files with 438 additions and 6 deletions
+16 -5
View File
@@ -68,10 +68,21 @@ Logs in via the front-end `[us_student_login]` shortcode. Can:
## Instructor Management
The studio admin gets an **Instructors** admin page (gated by `manage_instructors`)
to add an instructor — creating the WP user with the `us_instructor` role and to
toggle that instructor's per-capability access (e.g. whether they may manage their
own offerings/questions or export payments). The studio admin cannot grant a
capability it does not itself hold.
to add an instructor — creating the WP user with the `us_instructor` role and
emailing them a set-password link — and to toggle that instructor's per-capability
access. The managed capabilities are `manage_offerings`, `manage_questions`,
`view_own_payments`, and `export_payments`; `manage_availability` and
`view_own_lessons` are core to every instructor and are not managed here. The
`us_instructor` role grants the managed capabilities by default, and the page
stores per-user overrides on top of the role.
A studio admin cannot grant a capability it does not itself hold: only
capabilities the acting user has (checked via `current_user_can()`, so an
administrator's dynamic grant counts) are offered as toggles, and on creation any
managed capability the admin lacks is denied on the new instructor so they never
exceed the creator. The grantable/assignment rules live in the pure, unit-tested
`Auth\InstructorCapabilities`; `InstructorController` applies the result to the
`WP_User`.
## Implementation
- Class: `Unsupervised\Schedular\Auth\RoleManager`
@@ -81,4 +92,4 @@ capability it does not itself hold.
## Tests
- `tests/Unit/Auth/RoleManagerTest.php`
- `tests/Unit/Auth/InstructorControllerTest.php`
- `tests/Unit/Auth/InstructorCapabilitiesTest.php`
+16 -1
View File
@@ -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 );
}
+64
View File
@@ -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;
}
}
+159
View File
@@ -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 );
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var \WP_User $instructor
* @var array<string, array{granted: bool, grantable: bool}> $capabilities
* @var string $backUrl
* @var string $notice
*/
$capabilityLabels = [
'manage_offerings' => __('Manage their own offerings', 'unsupervised-schedular'),
'manage_questions' => __('Manage their own intake questions', 'unsupervised-schedular'),
'view_own_payments' => __('View their own payments report', 'unsupervised-schedular'),
'export_payments' => __('Export their own payments', 'unsupervised-schedular'),
];
?>
<div class="wrap">
<h1><?php echo esc_html($instructor->display_name); ?></h1>
<p><a href="<?php echo esc_url($backUrl); ?>">&larr; <?php esc_html_e('Back to instructors', 'unsupervised-schedular'); ?></a></p>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info inline"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<p><?php echo esc_html($instructor->user_email); ?></p>
<h2><?php esc_html_e('Capabilities', 'unsupervised-schedular'); ?></h2>
<p class="description"><?php esc_html_e('Every instructor manages their own availability and sees their own lessons. The options below are set per instructor — ones you do not hold yourself cannot be changed.', 'unsupervised-schedular'); ?></p>
<form method="post">
<?php wp_nonce_field('usc_instructor_action'); ?>
<input type="hidden" name="usc_action" value="update_caps">
<input type="hidden" name="instructor_id" value="<?php echo esc_attr((string) $instructor->ID); ?>">
<table class="form-table">
<?php foreach ($capabilities as $cap => $state) : ?>
<tr>
<th scope="row"><?php echo esc_html($capabilityLabels[$cap] ?? $cap); ?></th>
<td>
<?php if ($state['grantable']) : ?>
<label>
<input type="checkbox" name="capabilities[]" value="<?php echo esc_attr($cap); ?>" <?php checked($state['granted']); ?>>
<?php esc_html_e('Allowed', 'unsupervised-schedular'); ?>
</label>
<?php else : ?>
<span class="description">
<?php echo $state['granted'] ? esc_html__('Allowed', 'unsupervised-schedular') : esc_html__('Not allowed', 'unsupervised-schedular'); ?>
— <?php esc_html_e('you cannot change this because you do not hold this capability yourself.', 'unsupervised-schedular'); ?>
</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php submit_button(esc_html__('Save Capabilities', 'unsupervised-schedular')); ?>
</form>
</div>
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var list<array{id: int, name: string, email: string, registered: string}> $instructors
* @var string $pageSlug
* @var string $notice
*/
?>
<div class="wrap">
<h1><?php esc_html_e('Instructors', 'unsupervised-schedular'); ?></h1>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info inline"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<h2><?php esc_html_e('Add an instructor', 'unsupervised-schedular'); ?></h2>
<form method="post">
<?php wp_nonce_field('usc_instructor_action'); ?>
<input type="hidden" name="usc_action" value="create">
<table class="form-table">
<tr>
<th><label for="display_name"><?php esc_html_e('Name', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="display_name" id="display_name" class="regular-text"></td>
</tr>
<tr>
<th><label for="email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="email" name="email" id="email" class="regular-text" required>
<p class="description"><?php esc_html_e('Used as the login. The instructor is emailed a link to set their password.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Add Instructor', 'unsupervised-schedular')); ?>
</form>
<h2><?php esc_html_e('Current instructors', 'unsupervised-schedular'); ?></h2>
<?php if (empty($instructors)) : ?>
<p><?php esc_html_e('No instructors yet.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($instructors as $instructor) : ?>
<?php $detailUrl = add_query_arg(['page' => $pageSlug, 'instructor_id' => $instructor['id']], admin_url('admin.php')); ?>
<tr>
<td><a href="<?php echo esc_url($detailUrl); ?>"><?php echo esc_html($instructor['name']); ?></a></td>
<td><?php echo esc_html($instructor['email']); ?></td>
<td><?php echo esc_html($instructor['registered']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Unsupervised\Schedular\Auth\InstructorCapabilities;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class InstructorCapabilitiesTest extends TestCase
{
public function testGrantableReturnsOnlyManagedCapsTheAdminHolds(): void
{
$grantable = InstructorCapabilities::grantable([
RoleManager::CAP_MANAGE_OFFERINGS => true,
RoleManager::CAP_EXPORT_PAYMENTS => true,
// A non-instructor cap the admin holds is irrelevant here.
RoleManager::CAP_MANAGE_POLICIES => true,
]);
self::assertContains(RoleManager::CAP_MANAGE_OFFERINGS, $grantable);
self::assertContains(RoleManager::CAP_EXPORT_PAYMENTS, $grantable);
self::assertNotContains(RoleManager::CAP_MANAGE_QUESTIONS, $grantable);
self::assertNotContains(RoleManager::CAP_MANAGE_POLICIES, $grantable);
}
public function testGrantableExcludesCapsTheAdminLacksOrHasDisabled(): void
{
self::assertSame([], InstructorCapabilities::grantable([
RoleManager::CAP_MANAGE_OFFERINGS => false,
]));
self::assertSame([], InstructorCapabilities::grantable([]));
}
public function testResolveMapsEachGrantableCapToWhetherItWasSubmitted(): void
{
$resolved = InstructorCapabilities::resolve(
[RoleManager::CAP_MANAGE_OFFERINGS],
[RoleManager::CAP_MANAGE_OFFERINGS, RoleManager::CAP_EXPORT_PAYMENTS]
);
self::assertTrue($resolved[RoleManager::CAP_MANAGE_OFFERINGS]);
self::assertFalse($resolved[RoleManager::CAP_EXPORT_PAYMENTS]);
}
public function testResolveIgnoresSubmittedCapsOutsideTheGrantableSet(): void
{
// The form posts a cap the admin may not grant; it must be dropped, so a
// studio admin can never assign a capability it does not itself hold.
$resolved = InstructorCapabilities::resolve(
[RoleManager::CAP_MANAGE_QUESTIONS, RoleManager::CAP_MANAGE_POLICIES],
[RoleManager::CAP_MANAGE_OFFERINGS]
);
self::assertSame([RoleManager::CAP_MANAGE_OFFERINGS => false], $resolved);
}
}