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); + } +}