diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index 64392ba..535a31b 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.md @@ -53,7 +53,7 @@ Group classes follow the same registration flow but enrol against an offering of kind `group_class`; see `group-classes.md`. ## Admin Interface -- **Scheduler** (`manage_options` only): all upcoming lessons across all instructors +- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors - **My Lessons** (`view_own_lessons`): upcoming lessons for the logged-in instructor ## Frontend Shortcodes diff --git a/docs/features/user-roles.md b/docs/features/user-roles.md index 9f17067..b40cd4b 100644 --- a/docs/features/user-roles.md +++ b/docs/features/user-roles.md @@ -10,9 +10,17 @@ Runs the studio. Logs in via standard wp-admin. Can: - Create instructor accounts and set/revoke each instructor's capabilities - Manage offerings, intake questions, and policies - Configure Stripe credentials and per-student billing overrides (card / e-transfer / comp) +- View the studio-wide scheduler (all upcoming lessons across instructors) - View the all-instructor payments report and export it -**Capabilities:** `read`, `manage_instructors`, `manage_offerings`, `manage_questions`, `manage_policies`, `manage_billing`, `view_all_payments`, `export_payments` +**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. ### Instructor (`us_instructor`) Created by the studio admin. Logs in via standard wp-admin. Can: @@ -41,6 +49,7 @@ Logs in via the front-end `[us_student_login]` shortcode. Can: | `manage_policies` | ✓ | | | Policies | | `manage_billing` | ✓ | | | Payments (Stripe + overrides) | | `book_lesson` | | | ✓ | Lesson booking / enrolment | +| `view_all_lessons` | ✓ | | | Scheduler dashboard | | `view_own_lessons` | | ✓ | ✓ | Lesson + group views | | `view_own_payments` | | ✓ | | Payment reporting | | `view_all_payments` | ✓ | | | Payment reporting | diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 0f7a7ca..61bccf0 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -32,11 +32,11 @@ class AdminMenu { } public function addPages(): void { - // Admin-only dashboard: all upcoming lessons. + // Studio-wide dashboard: all upcoming lessons across instructors. add_menu_page( __( 'Scheduler', 'unsupervised-schedular' ), __( 'Scheduler', 'unsupervised-schedular' ), - 'manage_options', + RoleManager::CAP_VIEW_ALL_LESSONS, 'us-scheduler', [ $this->lessonController, 'renderAdminDashboard' ], 'dashicons-calendar-alt', diff --git a/src/Auth/RoleManager.php b/src/Auth/RoleManager.php index c107dce..e68b3c2 100644 --- a/src/Auth/RoleManager.php +++ b/src/Auth/RoleManager.php @@ -18,29 +18,44 @@ class RoleManager { public const CAP_MANAGE_QUESTIONS = 'manage_questions'; public const CAP_MANAGE_POLICIES = 'manage_policies'; public const CAP_MANAGE_BILLING = 'manage_billing'; + public const CAP_VIEW_ALL_LESSONS = 'view_all_lessons'; public const CAP_VIEW_ALL_PAYMENTS = 'view_all_payments'; public const CAP_VIEW_OWN_PAYMENTS = 'view_own_payments'; public const CAP_EXPORT_PAYMENTS = 'export_payments'; + /** + * Capabilities granted to the `us_studio_admin` role, and implicitly to any + * WordPress administrator (see {@see grantStudioCapsToAdministrators()}). + * + * @var list + */ + public const STUDIO_ADMIN_CAPS = [ + self::CAP_MANAGE_INSTRUCTORS, + self::CAP_MANAGE_OFFERINGS, + self::CAP_MANAGE_QUESTIONS, + self::CAP_MANAGE_POLICIES, + self::CAP_MANAGE_BILLING, + self::CAP_VIEW_ALL_LESSONS, + self::CAP_VIEW_ALL_PAYMENTS, + self::CAP_EXPORT_PAYMENTS, + ]; + public function register(): void { add_action( 'init', [ $this, 'createRoles' ] ); + add_filter( 'user_has_cap', [ $this, 'grantStudioCapsToAdministrators' ], 10, 1 ); } public function createRoles(): void { if ( get_role( self::STUDIO_ADMIN ) === null ) { + $studioCaps = [ 'read' => true ]; + foreach ( self::STUDIO_ADMIN_CAPS as $cap ) { + $studioCaps[ $cap ] = true; + } + add_role( self::STUDIO_ADMIN, __( 'Studio Admin', 'unsupervised-schedular' ), - [ - 'read' => true, - self::CAP_MANAGE_INSTRUCTORS => true, - self::CAP_MANAGE_OFFERINGS => true, - self::CAP_MANAGE_QUESTIONS => true, - self::CAP_MANAGE_POLICIES => true, - self::CAP_MANAGE_BILLING => true, - self::CAP_VIEW_ALL_PAYMENTS => true, - self::CAP_EXPORT_PAYMENTS => true, - ] + $studioCaps ); } @@ -72,4 +87,28 @@ class RoleManager { ); } } + + /** + * Grant every studio-admin capability to WordPress administrators. + * + * 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. + * + * @param array $allcaps All capabilities currently held by the user. + * @return array + */ + public function grantStudioCapsToAdministrators( array $allcaps ): array { + if ( empty( $allcaps['manage_options'] ) ) { + return $allcaps; + } + + foreach ( self::STUDIO_ADMIN_CAPS as $cap ) { + $allcaps[ $cap ] = true; + } + + return $allcaps; + } } diff --git a/src/Booking/LessonController.php b/src/Booking/LessonController.php index 710ea91..774393a 100644 --- a/src/Booking/LessonController.php +++ b/src/Booking/LessonController.php @@ -10,7 +10,7 @@ class LessonController { public function __construct( private BookingRepository $repository ) {} public function renderAdminDashboard(): void { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) ); } diff --git a/tests/Unit/Auth/RoleManagerTest.php b/tests/Unit/Auth/RoleManagerTest.php index 6ef790a..a496944 100644 --- a/tests/Unit/Auth/RoleManagerTest.php +++ b/tests/Unit/Auth/RoleManagerTest.php @@ -9,15 +9,36 @@ use Unsupervised\Schedular\Tests\Unit\TestCase; class RoleManagerTest extends TestCase { - public function testRegisterAddsInitHook(): void + public function testRegisterAddsInitHookAndCapFilter(): void { Functions\expect('add_action') ->once() ->with('init', \Mockery::any()); + Functions\expect('add_filter') + ->once() + ->with('user_has_cap', \Mockery::any(), 10, 1); + (new RoleManager())->register(); } + public function testGrantsStudioCapsToAdministrators(): void + { + $result = (new 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 + { + $result = (new RoleManager())->grantStudioCapsToAdministrators(['read' => true]); + + self::assertArrayNotHasKey(RoleManager::CAP_MANAGE_OFFERINGS, $result); + self::assertSame(['read' => true], $result); + } + public function testCreateRolesSkipsExistingRoles(): void { Functions\when('get_role')->alias(static fn() => new \stdClass());