Merge pull request 'Make WP admins instructors too, with an Access toggle page' (#29) from feature/admin-access-control into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.3) (push) Successful in 47s
CI / Coding Standards (push) Successful in 54s
CI / Tests (PHP 8.1) (push) Successful in 59s
CI / Tests (PHP 8.2) (push) Successful in 1m0s
CI / PHPStan (push) Successful in 1m14s
CI / Build Plugin Zip (push) Successful in 57s
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.3) (push) Successful in 47s
CI / Coding Standards (push) Successful in 54s
CI / Tests (PHP 8.1) (push) Successful in 59s
CI / Tests (PHP 8.2) (push) Successful in 1m0s
CI / PHPStan (push) Successful in 1m14s
CI / Build Plugin Zip (push) Successful in 57s
Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user