From 67f8144a4aa077994f6d64f2f96ebc1f2f20065b Mon Sep 17 00:00:00 2001 From: James Griffin Date: Mon, 8 Jun 2026 16:39:41 -0300 Subject: [PATCH] Make WP admins instructors too, and add an Access toggle page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/features/user-roles.md | 21 ++++++-- src/AdminMenu.php | 22 +++++++++ src/Auth/AccessSettings.php | 67 ++++++++++++++++++++++++++ src/Auth/RoleManager.php | 58 ++++++++++++++++------ templates/admin/access.php | 50 +++++++++++++++++++ tests/Unit/Auth/AccessSettingsTest.php | 34 +++++++++++++ tests/Unit/Auth/RoleManagerTest.php | 49 +++++++++++++++++-- 7 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 src/Auth/AccessSettings.php create mode 100644 templates/admin/access.php create mode 100644 tests/Unit/Auth/AccessSettingsTest.php diff --git a/docs/features/user-roles.md b/docs/features/user-roles.md index b40cd4b..e77125e 100644 --- a/docs/features/user-roles.md +++ b/docs/features/user-roles.md @@ -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: diff --git a/src/AdminMenu.php b/src/AdminMenu.php index fb718d6..5479299 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -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, diff --git a/src/Auth/AccessSettings.php b/src/Auth/AccessSettings.php new file mode 100644 index 0000000..4e31882 --- /dev/null +++ b/src/Auth/AccessSettings.php @@ -0,0 +1,67 @@ +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 + } +} diff --git a/src/Auth/RoleManager.php b/src/Auth/RoleManager.php index 1db863c..9e647e5 100644 --- a/src/Auth/RoleManager.php +++ b/src/Auth/RoleManager.php @@ -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 + */ + 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 $allcaps All capabilities currently held by the user. * @return array @@ -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; diff --git a/templates/admin/access.php b/templates/admin/access.php new file mode 100644 index 0000000..260e1c4 --- /dev/null +++ b/templates/admin/access.php @@ -0,0 +1,50 @@ + +
+

+ +
+

+ +

+
+ +
+ + + + + + + +
+
+ +

+
+ +

+
+

+ +

+
+ +
+
diff --git a/tests/Unit/Auth/AccessSettingsTest.php b/tests/Unit/Auth/AccessSettingsTest.php new file mode 100644 index 0000000..f974487 --- /dev/null +++ b/tests/Unit/Auth/AccessSettingsTest.php @@ -0,0 +1,34 @@ +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()); + } +} diff --git a/tests/Unit/Auth/RoleManagerTest.php b/tests/Unit/Auth/RoleManagerTest.php index a496944..7bba0e2 100644 --- a/tests/Unit/Auth/RoleManagerTest.php +++ b/tests/Unit/Auth/RoleManagerTest.php @@ -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);