From b5c076c3d6992708585a849d2d68aee3b5451314 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Mon, 8 Jun 2026 17:02:46 -0300 Subject: [PATCH] Add Instructors admin page (create + per-capability access) 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 --- docs/features/user-roles.md | 21 ++- src/AdminMenu.php | 17 +- src/Auth/InstructorCapabilities.php | 64 +++++++ src/Auth/InstructorController.php | 159 ++++++++++++++++++ templates/admin/instructor-detail.php | 60 +++++++ templates/admin/instructors.php | 65 +++++++ .../Unit/Auth/InstructorCapabilitiesTest.php | 58 +++++++ 7 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 src/Auth/InstructorCapabilities.php create mode 100644 src/Auth/InstructorController.php create mode 100644 templates/admin/instructor-detail.php create mode 100644 templates/admin/instructors.php create mode 100644 tests/Unit/Auth/InstructorCapabilitiesTest.php diff --git a/docs/features/user-roles.md b/docs/features/user-roles.md index e77125e..3d031c3 100644 --- a/docs/features/user-roles.md +++ b/docs/features/user-roles.md @@ -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` diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 5479299..994a83f 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -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 ); } diff --git a/src/Auth/InstructorCapabilities.php b/src/Auth/InstructorCapabilities.php new file mode 100644 index 0000000..91bfa71 --- /dev/null +++ b/src/Auth/InstructorCapabilities.php @@ -0,0 +1,64 @@ + + */ + 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 $adminCaps The acting user's capabilities (cap => held). + * @return list + */ + 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 $submitted Capability names checked in the form. + * @param list $grantable Capabilities the admin may assign. + * @return array + */ + public static function resolve( array $submitted, array $grantable ): array { + $resolved = []; + foreach ( $grantable as $cap ) { + $resolved[ $cap ] = in_array( $cap, $submitted, true ); + } + + return $resolved; + } +} diff --git a/src/Auth/InstructorController.php b/src/Auth/InstructorController.php new file mode 100644 index 0000000..fceb4a7 --- /dev/null +++ b/src/Auth/InstructorController.php @@ -0,0 +1,159 @@ +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 + */ + private function grantableCaps(): array { + $caps = []; + foreach ( InstructorCapabilities::MANAGED as $cap ) { + $caps[ $cap ] = current_user_can( $cap ); + } + + return InstructorCapabilities::grantable( $caps ); + } +} diff --git a/templates/admin/instructor-detail.php b/templates/admin/instructor-detail.php new file mode 100644 index 0000000..0f5bd04 --- /dev/null +++ b/templates/admin/instructor-detail.php @@ -0,0 +1,60 @@ + $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'), +]; +?> +
+

display_name); ?>

+

+ + +

+ + +

user_email); ?>

+ +

+

+
+ + + + + $state) : ?> + + + + + +
+ + + + + + — + + +
+ +
+
diff --git a/templates/admin/instructors.php b/templates/admin/instructors.php new file mode 100644 index 0000000..1365454 --- /dev/null +++ b/templates/admin/instructors.php @@ -0,0 +1,65 @@ + $instructors + * @var string $pageSlug + * @var string $notice + */ +?> +
+

+ + +

+ + +

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

+
+ +
+ +

+ +

+ + + + + + + + + + + + $pageSlug, 'instructor_id' => $instructor['id']], admin_url('admin.php')); ?> + + + + + + + +
+ +
diff --git a/tests/Unit/Auth/InstructorCapabilitiesTest.php b/tests/Unit/Auth/InstructorCapabilitiesTest.php new file mode 100644 index 0000000..edfdfee --- /dev/null +++ b/tests/Unit/Auth/InstructorCapabilitiesTest.php @@ -0,0 +1,58 @@ + 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); + } +}