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

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:
2026-06-08 17:02:46 -03:00
parent fdadd2fb92
commit b5c076c3d6
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);
}
}