diff --git a/CHANGELOG.md b/CHANGELOG.md index 105a748..cd7eab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ each change under the current top section as you work. ## [1.3.1] +### Added +- An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header. + ### Security - Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered. diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 55f7e5a..98a9d9d 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -549,6 +549,29 @@ color: #1a7d2e; } +/* + * The account panel: who is signed in, and the way out. Sized to sit in a + * header or sidebar, so the rules stay minimal and inherit the theme's type — + * a block that lands in a site header should look like it belongs there. + */ +.us-account p { + margin: 0 0 4px; +} + +.us-account-name { + font-weight: 600; +} + +.us-account-email { + display: block; + font-size: 0.9em; + opacity: 0.75; +} + +.us-account-actions { + margin-top: 8px; +} + /* Shown only in block-editor previews (see BlockPreview). */ .us-editor-note { font-size: 0.85em; diff --git a/assets/js/blocks.js b/assets/js/blocks.js index d643f28..8971ce0 100644 --- a/assets/js/blocks.js +++ b/assets/js/blocks.js @@ -307,6 +307,28 @@ }) ), }, + { + name: 'us-scheduler/account', + title: __('Account', 'unsupervised-schedular'), + description: __('Shows the name and email of whoever is signed in, with a sign out link. Renders nothing for signed-out visitors unless a login page is chosen.', 'unsupervised-schedular'), + icon: 'admin-users', + keywords: ['account', 'sign out', 'log out', 'signed in', 'profile'], + shortcode: 'us_account', + attributes: { + loginPageId: { type: 'number', default: 0 }, + }, + inspector: (attributes, setAttributes) => el( + PanelBody, + { title: __('Signing in and out', 'unsupervised-schedular') }, + el(PageSelect, { + label: __('Login page', 'unsupervised-schedular'), + help: __('Where signing out returns to, and where signed-out visitors are offered a link to sign in. Without one, signing out returns to the current page and signed-out visitors see nothing.', 'unsupervised-schedular'), + defaultLabel: __('Stay on the current page', 'unsupervised-schedular'), + value: attributes.loginPageId, + onChange: (loginPageId) => setAttributes({ loginPageId }), + }) + ), + }, ]; blocks.forEach((def) => { diff --git a/docs/features/editor-blocks.md b/docs/features/editor-blocks.md index 3383256..51d826f 100644 --- a/docs/features/editor-blocks.md +++ b/docs/features/editor-blocks.md @@ -1,8 +1,8 @@ # Editor Blocks -Gutenberg dynamic-block wrappers for the plugin's four front-end shortcodes, -so the pages can be previewed and styled inside the block editor instead of -appearing as grey shortcode text. +Gutenberg dynamic-block wrappers for the plugin's front-end shortcodes, so the +pages can be previewed and styled inside the block editor instead of appearing +as grey shortcode text. ## Blocks @@ -12,6 +12,8 @@ appearing as grey shortcode text. | `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` | | `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` | | `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` | +| `us-scheduler/family` | `[us_family]` | `Guardian\FamilyPage::render()` | +| `us-scheduler/account` | `[us_account]` | `Auth\AccountPage::render()` | The shortcodes remain registered for back-compat; blocks and shortcodes share the same page objects (constructed once in `Plugin::boot()`), so front-end @@ -21,7 +23,7 @@ transform. ## Block options -Four blocks have sidebar (inspector) options: +Most blocks have sidebar (inspector) options: | Block | Attribute | Default | Effect | |---|---|---|---| @@ -34,6 +36,8 @@ Four blocks have sidebar (inspector) options: | `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. | | `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. | | `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. | +| `us-scheduler/family` | `loginPageId` (number) | `0` | Where visitors who are not signed in are sent to log in. Shortcode equivalent: `[us_family login_page_id="…"]`. | +| `us-scheduler/account` | `loginPageId` (number) | `0` | Where signing out returns to, and where a signed-out visitor is offered a **Sign in** link. `0` = signing out returns to the current page, and a signed-out visitor sees **nothing at all** — see below. Shortcode equivalent: `[us_account login_page_id="…"]`. | | `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. | The page selects list all published pages; if a chosen page is later deleted, @@ -105,6 +109,10 @@ placeholder content: - **Login** — the real `templates/frontend/login-page.php` template (it has no request-state dependencies). - **Registration** — a disabled sample of the `.us-register-form` fields. +- **Account** — a populated sample panel. Deliberately populated whatever the + editor user's own state: on the published page a signed-out visitor may see + nothing at all, and an empty box tells the person placing the block nothing + about where it will sit. Each preview starts with a `.us-editor-note` paragraph explaining what the published page shows instead. The note class only appears in editor previews. @@ -118,5 +126,22 @@ published page shows instead. The note class only appears in editor previews. and fallbacks. - `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and fallbacks. +- `tests/Unit/Auth/AccountPageTest.php` — what each visitor sees, the + sign-out redirect target, and the signed-out empty render. - `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS classes/ids and includes the editor note. + +## The account block's signed-out behaviour + +`us-scheduler/account` is the one block that can render **nothing**. It is meant +for a header, sidebar or account page, and its whole subject is the person +signed in — which a stranger is not. A bare "you are not signed in" in a site +header is noise that cannot be acted on, so: + +- **No login page chosen** → empty string for signed-out visitors. +- **Login page chosen** → a single **Sign in** link. + +Signed in, it shows the display name (`Auth\UserName::format()`, so a username +is never exposed), the account email, and a **Sign out** link — deliberately +nothing else. Signing out returns to the chosen login page, or to the current page when there +is none, so a header sign-out does not also navigate the visitor somewhere. diff --git a/src/Auth/AccountPage.php b/src/Auth/AccountPage.php new file mode 100644 index 0000000..3e89637 --- /dev/null +++ b/src/Auth/AccountPage.php @@ -0,0 +1,78 @@ + $atts Block attributes (`loginPageId`) or + * shortcode attributes (`login_page_id`). + */ + public function render( array $atts ): string { + $loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ); + $loginUrl = $this->pageUrl( $loginPageId ); + + wp_enqueue_style( 'us-scheduler' ); + + if ( ! is_user_logged_in() ) { + if ( null === $loginUrl ) { + return ''; + } + + return sprintf( + '
%s
', + esc_url( $loginUrl ), + esc_html__( 'Sign in', 'unsupervised-schedular' ) + ); + } + + // Always a WP_User here — is_user_logged_in() above rules out the + // id-0 placeholder wp_get_current_user() returns for a visitor. + $user = wp_get_current_user(); + + $name = UserName::format( $user, get_current_user_id() ); + $email = $user->user_email; + + // Back to where they were, so signing out of a header link does not also + // navigate them somewhere. The login page is the better landing spot when + // one is configured, since the current page may be members-only. + $logoutUrl = wp_logout_url( $loginUrl ?? (string) get_permalink() ); + + ob_start(); + include USC_PLUGIN_DIR . 'templates/frontend/account-page.php'; + return (string) ob_get_clean(); + } + + /** + * Permalink of a configured page, or null when none is chosen or the chosen + * page has since been deleted. + */ + private function pageUrl( int $pageId ): ?string { + if ( $pageId <= 0 ) { + return null; + } + + $url = get_permalink( $pageId ); + + return is_string( $url ) ? $url : null; + } +} diff --git a/src/BlockPreview.php b/src/BlockPreview.php index 47bce0d..c1c3f65 100644 --- a/src/BlockPreview.php +++ b/src/BlockPreview.php @@ -210,6 +210,24 @@ class BlockPreview { ); } + /** + * Sample account panel. Shown populated whatever the editor's own login + * state, since on the published page a signed-out visitor may see nothing at + * all and an empty box tells the person placing the block nothing. + */ + public static function account(): string { + return sprintf( + '
%s' + . '

' + . '

' + . '

', + self::note( __( 'Editor preview — each visitor sees their own account here.', 'unsupervised-schedular' ) ), + esc_html__( 'Grace Hopper', 'unsupervised-schedular' ), + esc_html__( 'grace@example.com', 'unsupervised-schedular' ), + esc_html__( 'Sign out', 'unsupervised-schedular' ) + ); + } + private static function note( string $text ): string { return '

' . esc_html( $text ) . '

'; } diff --git a/src/BlockRegistrar.php b/src/BlockRegistrar.php index f97c3c6..c59e1ff 100644 --- a/src/BlockRegistrar.php +++ b/src/BlockRegistrar.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular; +use Unsupervised\Schedular\Auth\AccountPage; use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Booking\BookingPage; @@ -30,6 +31,7 @@ class BlockRegistrar { private RegistrationPage $registrationPage, private GroupClassPage $groupClassPage, private FamilyPage $familyPage, + private AccountPage $accountPage, ) {} public function register(): void { @@ -148,6 +150,15 @@ class BlockRegistrar { ], ], ], + 'us-scheduler/account' => [ + 'render' => [ $this, 'renderAccount' ], + 'attributes' => [ + 'loginPageId' => [ + 'type' => 'number', + 'default' => 0, + ], + ], + ], ]; } @@ -195,6 +206,15 @@ class BlockRegistrar { return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 ); } + /** + * Renders the account (who is signed in) block. + * + * @param array $attributes Block attributes. + */ + public function renderAccount( array $attributes = [] ): string { + return $this->isEditorPreview() ? BlockPreview::account() : $this->accountPage->render( $attributes ); + } + /** * Renders the family (manage-children) block. * diff --git a/src/Plugin.php b/src/Plugin.php index 889b966..394f92e 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular; use Unsupervised\Schedular\Auth\EmailConfirmationHandler; use Unsupervised\Schedular\Auth\InviteRepository; +use Unsupervised\Schedular\Auth\AccountPage; use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\RegistrationLoginGate; use Unsupervised\Schedular\Auth\RegistrationMailer; @@ -99,6 +100,7 @@ class Plugin { $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians ); $groupClassPage = new GroupClassPage( $guardians ); $familyPage = new FamilyPage( $guardians, $questions, $answers ); + $accountPage = new AccountPage(); ( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register(); @@ -110,7 +112,7 @@ class Plugin { ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register(); ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register(); - ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register(); - ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register(); + ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register(); + ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register(); } } diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php index 42d713c..977af61 100644 --- a/src/ShortcodeRegistrar.php +++ b/src/ShortcodeRegistrar.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular; +use Unsupervised\Schedular\Auth\AccountPage; use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Booking\BookingPage; @@ -18,6 +19,7 @@ class ShortcodeRegistrar { private RegistrationPage $registrationPage, private GroupClassPage $groupClassPage, private FamilyPage $familyPage, + private AccountPage $accountPage, ) {} public function register(): void { @@ -26,6 +28,7 @@ class ShortcodeRegistrar { add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) ); add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) ); add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) ); + add_shortcode( 'us_account', self::shortcode( [ $this->accountPage, 'render' ] ) ); // Process registration submissions before output so the invite branch's // auth cookie is actually sent (render() runs too late, during the_content). add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] ); diff --git a/templates/frontend/account-page.php b/templates/frontend/account-page.php new file mode 100644 index 0000000..b94a444 --- /dev/null +++ b/templates/frontend/account-page.php @@ -0,0 +1,25 @@ + +
+ + + +
diff --git a/tests/Unit/Auth/AccountPageTest.php b/tests/Unit/Auth/AccountPageTest.php new file mode 100644 index 0000000..a6b8708 --- /dev/null +++ b/tests/Unit/Auth/AccountPageTest.php @@ -0,0 +1,117 @@ +page = new AccountPage(); + + Functions\when('is_user_logged_in')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(5); + Functions\when('wp_enqueue_style')->justReturn(null); + Functions\when('get_permalink')->alias( + static fn (int $id = 0): string => $id > 0 + ? 'https://studio.test/sign-in/' + : 'https://studio.test/current/' + ); + Functions\when('wp_logout_url')->alias( + static fn (string $redirect): string => 'https://studio.test/wp-login.php?action=logout&redirect_to=' . rawurlencode($redirect) + ); + Functions\when('wp_get_current_user')->justReturn($this->user('Grace', 'Hopper', 'grace@studio.test')); + } + + private function user(string $first, string $last, string $email): \WP_User + { + $user = Mockery::mock(\WP_User::class); + $user->ID = 5; + $user->first_name = $first; + $user->last_name = $last; + $user->nickname = ''; + $user->user_email = $email; + + return $user; + } + + public function testShowsTheSignedInNameAndEmail(): void + { + $html = $this->page->render([]); + + self::assertStringContainsString('Grace Hopper', $html); + self::assertStringContainsString('grace@studio.test', $html); + self::assertStringContainsString('Sign out', $html); + } + + public function testSigningOutReturnsToTheConfiguredLoginPage(): void + { + self::assertStringContainsString( + rawurlencode('https://studio.test/sign-in/'), + $this->page->render(['loginPageId' => 9]) + ); + } + + /** + * With no page chosen, signing out from a header link should leave the + * visitor where they were rather than navigating them somewhere. + */ + public function testSigningOutReturnsToTheCurrentPageWhenNoLoginPageIsSet(): void + { + self::assertStringContainsString( + rawurlencode('https://studio.test/current/'), + $this->page->render([]) + ); + } + + public function testTheShortcodeAttributeNameIsAccepted(): void + { + self::assertStringContainsString( + rawurlencode('https://studio.test/sign-in/'), + $this->page->render(['login_page_id' => 9]) + ); + } + + /** + * A block whose whole job is "you are signed in as X" has nothing to say to + * a stranger, and a bare notice in a site header cannot be acted on. + */ + public function testRendersNothingForASignedOutVisitorWithNoLoginPage(): void + { + Functions\when('is_user_logged_in')->justReturn(false); + + self::assertSame('', $this->page->render([])); + } + + public function testOffersASignInLinkToASignedOutVisitorWhenAPageIsChosen(): void + { + Functions\when('is_user_logged_in')->justReturn(false); + + $html = $this->page->render(['loginPageId' => 9]); + + self::assertStringContainsString('https://studio.test/sign-in/', $html); + self::assertStringContainsString('Sign in', $html); + self::assertStringNotContainsString('Sign out', $html); + } + + /** + * A page can be deleted after it has been chosen in the block, which + * get_permalink() reports as false. + */ + public function testTreatsADeletedLoginPageAsNoneChosen(): void + { + Functions\when('is_user_logged_in')->justReturn(false); + Functions\when('get_permalink')->justReturn(false); + + self::assertSame('', $this->page->render(['loginPageId' => 9])); + } +} diff --git a/tests/Unit/BlockRegistrarTest.php b/tests/Unit/BlockRegistrarTest.php index 9401247..3084a6b 100644 --- a/tests/Unit/BlockRegistrarTest.php +++ b/tests/Unit/BlockRegistrarTest.php @@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit; use Brain\Monkey\Actions; use Brain\Monkey\Functions; use Mockery; +use Unsupervised\Schedular\Auth\AccountPage; use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\BlockRegistrar; @@ -43,6 +44,7 @@ class BlockRegistrarTest extends TestCase private RegistrationPage&Mockery\MockInterface $registrationPage; private GroupClassPage&Mockery\MockInterface $groupClassPage; private FamilyPage&Mockery\MockInterface $familyPage; + private AccountPage&Mockery\MockInterface $accountPage; private TestableBlockRegistrar $registrar; protected function setUp(): void @@ -54,6 +56,7 @@ class BlockRegistrarTest extends TestCase $this->registrationPage = Mockery::mock(RegistrationPage::class); $this->groupClassPage = Mockery::mock(GroupClassPage::class); $this->familyPage = Mockery::mock(FamilyPage::class); + $this->accountPage = Mockery::mock(AccountPage::class); // Most requests are not a just-finished registration; the tests that // exercise that path override this. @@ -67,6 +70,7 @@ class BlockRegistrarTest extends TestCase $this->registrationPage, $this->groupClassPage, $this->familyPage, + $this->accountPage, ); } @@ -116,6 +120,7 @@ class BlockRegistrarTest extends TestCase 'us-scheduler/student-register', 'us-scheduler/group-classes', 'us-scheduler/family', + 'us-scheduler/account', ], array_keys($registered) ); @@ -213,6 +218,7 @@ class BlockRegistrarTest extends TestCase $this->registrationPage, $this->groupClassPage, $this->familyPage, + $this->accountPage, ); $this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live'); diff --git a/tests/Unit/ShortcodeRegistrarTest.php b/tests/Unit/ShortcodeRegistrarTest.php index 790ff17..726a9cf 100644 --- a/tests/Unit/ShortcodeRegistrarTest.php +++ b/tests/Unit/ShortcodeRegistrarTest.php @@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit; use Brain\Monkey\Actions; use Brain\Monkey\Functions; use Mockery; +use Unsupervised\Schedular\Auth\AccountPage; use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Booking\BookingPage; @@ -20,6 +21,7 @@ class ShortcodeRegistrarTest extends TestCase private RegistrationPage&Mockery\MockInterface $registrationPage; private GroupClassPage&Mockery\MockInterface $groupClassPage; private FamilyPage&Mockery\MockInterface $familyPage; + private AccountPage&Mockery\MockInterface $accountPage; private ShortcodeRegistrar $registrar; /** @var array */ @@ -37,6 +39,7 @@ class ShortcodeRegistrarTest extends TestCase $this->registrationPage = Mockery::mock(RegistrationPage::class); $this->groupClassPage = Mockery::mock(GroupClassPage::class); $this->familyPage = Mockery::mock(FamilyPage::class); + $this->accountPage = Mockery::mock(AccountPage::class); $this->registrar = new ShortcodeRegistrar( $this->bookingPage, @@ -44,6 +47,7 @@ class ShortcodeRegistrarTest extends TestCase $this->registrationPage, $this->groupClassPage, $this->familyPage, + $this->accountPage, ); $shortcodes = &$this->shortcodes; @@ -66,7 +70,7 @@ class ShortcodeRegistrarTest extends TestCase $this->registrar->register(); self::assertSame( - ['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family'], + ['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family', 'us_account'], array_keys($this->shortcodes) ); }