Make WP admins instructors too, and add an Access toggle page
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 41s
CI / Tests (PHP 8.3) (pull_request) Successful in 51s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / Coding Standards (pull_request) Successful in 58s
CI / PHPStan (pull_request) Successful in 1m9s
CI / Build Plugin Zip (pull_request) Has been skipped

A WordPress administrator previously inherited the studio-admin
capabilities but not `manage_availability`, so the studio owner running
as an admin had no way to reach "My Availability" or act as the
instructor — breaking single-instructor businesses.

Grant the instructor capabilities to administrators as well (via the
existing `user_has_cap` filter), and make both grants — studio-admin and
instructor — independently toggleable from a new Access admin page.

- RoleManager: extract `INSTRUCTOR_CAPS`; apply studio and instructor
  cap sets to administrators, each gated on a stored toggle (default on).
- AccessSettings + templates/admin/access.php: two options
  (`us_admin_grant_studio` / `us_admin_grant_instructor`), gated on the
  core `manage_options` capability so disabling a grant can never lock an
  administrator out of re-enabling it.
- AdminMenu: register the Access page after Studio Settings; keep the
  studio sidebar separator visible for any administrator.
- Tests for the toggles and the new settings reader; docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:39:41 -03:00
parent 0a78f4b1ac
commit 67f8144a4a
7 changed files with 279 additions and 22 deletions
+16 -5
View File
@@ -16,11 +16,22 @@ Runs the studio. Logs in via standard wp-admin. Can:
**Capabilities:** `read`, `manage_instructors`, `manage_offerings`, `manage_questions`, `manage_policies`, `manage_billing`, `view_all_lessons`, `view_all_payments`, `export_payments`
> Any WordPress **administrator** (`manage_options`) implicitly holds every
> studio-admin capability above, so the site owner runs the studio without being
> assigned the `us_studio_admin` role. This is applied dynamically via the
> `user_has_cap` filter (`RoleManager::grantStudioCapsToAdministrators()`) — it
> persists nothing and is removed when the plugin is deactivated. The
> `us_studio_admin` role exists for non-administrator staff who manage the studio.
> studio-admin capability above **and the instructor capabilities** (notably
> `manage_availability`), so a single-instructor studio owner can run the business
> and teach — managing their own availability and lessons — from one account,
> without being assigned the `us_studio_admin` or `us_instructor` role. This is
> applied dynamically via the `user_has_cap` filter
> (`RoleManager::grantStudioCapsToAdministrators()`) — it persists nothing and is
> removed when the plugin is deactivated. The `us_studio_admin` role exists for
> non-administrator staff who manage the studio.
>
> Both grants can be turned off independently on the **Access** admin page
> (`AccessSettings`, options `us_admin_grant_studio` / `us_admin_grant_instructor`,
> both default on) — for example, when dedicated `us_studio_admin` or
> `us_instructor` accounts run the studio and administrators should not appear as
> studio staff. That page is gated on the core `manage_options` capability (which
> the plugin never grants or revokes), so an administrator can always reach it to
> re-enable a grant; disabling one can never lock them out.
### Instructor (`us_instructor`)
Created by the studio admin. Logs in via standard wp-admin. Can:
+22
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Availability\AvailabilityController;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RoleManager;
@@ -39,6 +40,7 @@ class AdminMenu {
private GroupClassController $groupClassController;
private StudentController $studentController;
private StudioSettings $settings;
private AccessSettings $accessSettings;
private PaymentController $paymentController;
private PaymentReportController $paymentReportController;
@@ -52,6 +54,7 @@ class AdminMenu {
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver );
$this->settings = $settings;
$this->accessSettings = new AccessSettings();
$this->paymentController = new PaymentController( $payments, $paymentService );
$this->paymentReportController = new PaymentReportController( $payments );
}
@@ -185,6 +188,19 @@ class AdminMenu {
30
);
// Site owner: whether WordPress administrators are studio admins / instructors.
// Gated on the core manage_options capability — never the plugin's own grants —
// so an administrator can always reach it to re-enable a disabled grant.
add_menu_page(
__( 'Access', 'unsupervised-schedular' ),
__( 'Access', 'unsupervised-schedular' ),
'manage_options',
'us-access',
[ $this->accessSettings, 'renderPage' ],
'dashicons-admin-network',
30.5
);
// Instructor: view their upcoming lessons.
add_menu_page(
__( 'My Lessons', 'unsupervised-schedular' ),
@@ -234,6 +250,12 @@ class AdminMenu {
}
private function userSeesStudioMenu(): bool {
// Administrators always see the Access page, so the leading separator
// should show for them even if both capability grants are disabled.
if ( current_user_can( 'manage_options' ) ) {
return true;
}
$caps = [
RoleManager::CAP_VIEW_ALL_LESSONS,
RoleManager::CAP_VIEW_LESSONS,
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* Site-owner toggles for whether WordPress administrators automatically receive
* the studio-admin and/or instructor capabilities.
*
* Both default on, preserving the out-of-the-box experience where a single
* administrator runs the studio and teaches from one account. The settings page
* is gated on `manage_options` (the core WordPress administrator capability,
* which the plugin never grants or revokes) so an administrator can always reach
* it to re-enable a grant — disabling one can never lock them out.
*/
class AccessSettings {
public const OPT_GRANT_STUDIO = 'us_admin_grant_studio';
public const OPT_GRANT_INSTRUCTOR = 'us_admin_grant_instructor';
/**
* Whether WordPress administrators implicitly hold the studio-admin
* capabilities (offerings, policies, billing, reports, …).
*/
public function adminsAreStudioAdmins(): bool {
return $this->flag( self::OPT_GRANT_STUDIO );
}
/**
* Whether WordPress administrators implicitly hold the instructor
* capabilities (manage their own availability and lessons).
*/
public function adminsAreInstructors(): bool {
return $this->flag( self::OPT_GRANT_INSTRUCTOR );
}
/**
* Read a stored toggle, defaulting to on so a fresh install keeps the
* single-account behaviour.
*/
private function flag( string $option ): bool {
return '0' !== (string) get_option( $option, '1' );
}
public function renderPage(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to manage access settings.', 'unsupervised-schedular' ) );
}
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_access_action' ) ) {
$this->save();
}
$adminsAreStudioAdmins = $this->adminsAreStudioAdmins();
$adminsAreInstructors = $this->adminsAreInstructors();
include USC_PLUGIN_DIR . 'templates/admin/access.php';
}
private function save(): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
update_option( self::OPT_GRANT_STUDIO, isset( $_POST['grant_studio'] ) ? '1' : '0' );
update_option( self::OPT_GRANT_INSTRUCTOR, isset( $_POST['grant_instructor'] ) ? '1' : '0' );
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
}
+44 -14
View File
@@ -42,6 +42,26 @@ class RoleManager {
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,
];
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 );
@@ -62,18 +82,15 @@ class RoleManager {
}
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' ),
[
'read' => true,
self::CAP_MANAGE_AVAILABILITY => true,
self::CAP_MANAGE_OFFERINGS => true,
self::CAP_MANAGE_QUESTIONS => true,
self::CAP_VIEW_LESSONS => true,
self::CAP_VIEW_OWN_PAYMENTS => true,
self::CAP_EXPORT_PAYMENTS => true,
]
$instructorCaps
);
}
@@ -95,9 +112,14 @@ class RoleManager {
*
* 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. Applied dynamically via
* the `user_has_cap` filter, so nothing is persisted and the grant disappears
* when the plugin is deactivated.
* 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>
@@ -107,8 +129,16 @@ class RoleManager {
return $allcaps;
}
foreach ( self::STUDIO_ADMIN_CAPS as $cap ) {
$allcaps[ $cap ] = true;
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;
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var bool $adminsAreStudioAdmins
* @var bool $adminsAreInstructors
*/
?>
<div class="wrap">
<h1><?php esc_html_e('Access', 'unsupervised-schedular'); ?></h1>
<div class="notice notice-info inline">
<p>
<?php esc_html_e('Control whether WordPress administrators automatically gain studio-admin and instructor abilities. Both are on by default, so the site owner can run the studio and teach from one account. Turn them off when dedicated Studio Admin or Instructor accounts run the studio instead.', 'unsupervised-schedular'); ?>
</p>
</div>
<form method="post">
<?php wp_nonce_field('usc_access_action'); ?>
<input type="hidden" name="usc_action" value="save">
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('WordPress administrators', 'unsupervised-schedular'); ?></th>
<td>
<fieldset>
<label>
<input type="checkbox" name="grant_studio" value="1" <?php checked($adminsAreStudioAdmins); ?>>
<?php esc_html_e('Are Studio Admins', 'unsupervised-schedular'); ?>
</label>
<p class="description"><?php esc_html_e('Manage offerings, questions, policies, students, invites, billing, and reports.', 'unsupervised-schedular'); ?></p>
<br>
<label>
<input type="checkbox" name="grant_instructor" value="1" <?php checked($adminsAreInstructors); ?>>
<?php esc_html_e('Are Instructors', 'unsupervised-schedular'); ?>
</label>
<p class="description"><?php esc_html_e('Manage their own availability and see their own lessons — needed to set availability and be booked from the administrator account.', 'unsupervised-schedular'); ?></p>
</fieldset>
<p class="description">
<?php esc_html_e('You are a WordPress administrator, so this page stays available even if you turn both options off.', 'unsupervised-schedular'); ?>
</p>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Save Access Settings', 'unsupervised-schedular')); ?>
</form>
</div>
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class AccessSettingsTest extends TestCase
{
public function testBothGrantsDefaultOnWhenUnset(): void
{
// get_option returns the supplied default when the option is unset.
Functions\when('get_option')->alias(static fn(string $name, $default) => $default);
$settings = new AccessSettings();
self::assertTrue($settings->adminsAreStudioAdmins());
self::assertTrue($settings->adminsAreInstructors());
}
public function testStoredZeroDisablesAGrant(): void
{
Functions\when('get_option')->alias(static function (string $name) {
return $name === AccessSettings::OPT_GRANT_STUDIO ? '0' : '1';
});
$settings = new AccessSettings();
self::assertFalse($settings->adminsAreStudioAdmins());
self::assertTrue($settings->adminsAreInstructors());
}
}
+46 -3
View File
@@ -4,11 +4,21 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class RoleManagerTest extends TestCase
{
private function roleManager(bool $studio = true, bool $instructor = true): RoleManager
{
$access = \Mockery::mock(AccessSettings::class);
$access->allows('adminsAreStudioAdmins')->andReturn($studio);
$access->allows('adminsAreInstructors')->andReturn($instructor);
return new RoleManager($access);
}
public function testRegisterAddsInitHookAndCapFilter(): void
{
Functions\expect('add_action')
@@ -24,16 +34,49 @@ class RoleManagerTest extends TestCase
public function testGrantsStudioCapsToAdministrators(): void
{
$result = (new RoleManager())->grantStudioCapsToAdministrators(['manage_options' => true]);
$result = $this->roleManager()->grantStudioCapsToAdministrators(['manage_options' => true]);
foreach (RoleManager::STUDIO_ADMIN_CAPS as $cap) {
self::assertTrue($result[$cap], "administrator should be granted {$cap}");
}
}
public function testDoesNotGrantStudioCapsToNonAdministrators(): void
public function testGrantsInstructorCapsToAdministrators(): void
{
$result = (new RoleManager())->grantStudioCapsToAdministrators(['read' => true]);
$result = $this->roleManager()->grantStudioCapsToAdministrators(['manage_options' => true]);
// A single-instructor studio owner runs as an administrator and also
// teaches, so they get the instructor caps — notably manage_availability,
// which is not part of the studio-admin set.
self::assertTrue($result[RoleManager::CAP_MANAGE_AVAILABILITY], 'administrator should be able to manage availability');
foreach (RoleManager::INSTRUCTOR_CAPS as $cap) {
self::assertTrue($result[$cap], "administrator should be granted {$cap}");
}
}
public function testStudioGrantDisabledWithholdsStudioCapsFromAdministrators(): void
{
$result = $this->roleManager(studio: false)->grantStudioCapsToAdministrators(['manage_options' => true]);
// manage_instructors is studio-only, so it disappears when the grant is off.
self::assertArrayNotHasKey(RoleManager::CAP_MANAGE_INSTRUCTORS, $result);
// Instructor caps remain because that grant is still on.
self::assertTrue($result[RoleManager::CAP_MANAGE_AVAILABILITY]);
}
public function testInstructorGrantDisabledWithholdsInstructorCapsFromAdministrators(): void
{
$result = $this->roleManager(instructor: false)->grantStudioCapsToAdministrators(['manage_options' => true]);
// manage_availability is instructor-only, so it disappears when the grant is off.
self::assertArrayNotHasKey(RoleManager::CAP_MANAGE_AVAILABILITY, $result);
// Studio caps remain because that grant is still on.
self::assertTrue($result[RoleManager::CAP_MANAGE_INSTRUCTORS]);
}
public function testDoesNotGrantCapsToNonAdministrators(): void
{
$result = $this->roleManager()->grantStudioCapsToAdministrators(['read' => true]);
self::assertArrayNotHasKey(RoleManager::CAP_MANAGE_OFFERINGS, $result);
self::assertSame(['read' => true], $result);