Session log — s2l.online register-then-course bug fix

← All session logs

Session log — s2l.online register-then-course bug fix

26 April 2026 · Hasmukh with Claude · three pieces of work in one day: the register-then-course “login loop” bug, traced through curl and fixed in plugin and theme; a clean hide of two language pages whose courses are not yet ready; and the new S2L logo dropped into the site header.

Brief

1. How the bug surfaced

The day after the private content reviewer went live at reviewer.s2l.online/hasmukh/, Hasmukh used it for the first time. Instead of pasting marketing copy, he pasted a real complaint about his own site:

Pasted into the reviewerI am not happy with the s2l.online site as when I register and try a course it does not access the course and gives login again.

The reviewer treated it as a content review, flagged the wrong-genre problem, and reached the right conclusion (rethink the angle). At the bottom Claude added a separate operational note: if this is a real bug, point me at it and I will look into the site itself. Hasmukh said yes, look into it now.

Step 1

2. Tracing the flow with curl

The first instinct was to assume registration was broken. The first attempt did look broken — the test posted to /register/ with the wrong field names (name instead of full_name, csrf_token instead of ep_csrf_token) so no user was created and no cookie set. Resist the temptation to declare victory on a bad test.

The second attempt read the form first and pulled out the real field names plus the dynamic honeypot field ep_fe400309. With those right, registration worked perfectly:

  • POST to /register/ returned 302 to /
  • Server sent Set-Cookie: PM_USER=…; secure; HttpOnly; path=/; Max-Age=604800
  • The user record was created in pm_users
  • A subsequent GET on /watch/?course=sars-efiling&lesson=1 with that same cookie did not show the “Please log in” page. It showed “You need to enrol in this course first.” That is the auth-passed branch.
OutcomeRegistration, auto-login, cookie persistence, and the cookie-aware course viewer all worked. The system, looked at as data, was fine.
Step 2

3. Root cause: invisible login state

So why was Hasmukh seeing a login loop? The answer was on the home page itself. Inspecting the served HTML for a logged-in session showed it identical to the guest version:

  • Top-right CTA: <a href="/register" class="nav-cta">Start Learning Free</a>
  • Mobile nav red CTA: same target, /register
  • Footer: <li><a href="/register">Register Free</a></li><li><a href="/login">Login</a></li>
  • Anywhere there was an account/profile/logout link: zero matches

The user’s mental model was therefore correct. They had registered, the home page showed only “Register” and “Login” to them, so they assumed registration had failed and clicked Login again. The site never told them they were logged in. The complaint “gives login again” was an exact description of what the home page nav was offering them.

LessonAn auth flow can be cookie-correct and HTTP-correct and still feel completely broken if the visible UI does not change with login state. “Auth works in curl” is necessary, not sufficient.
Step 3

4. Designing the fix: a single shortcode

EP Membership already exposes

,
, Log out,
,

Please log in to view your dashboard.

, and . None of those swap a guest CTA for a member CTA in a nav, which is what was needed in three places: desktop nav, mobile nav, footer. PageMotor’s shortcode parser only passes inline attributes to handlers, so a self-closing [member-nav mode="cta|mobile|footer|inline"] was the cleanest fit.

The shortcode reads $motor->user and emits the right list-item HTML for each mode. Defaults for member-side: link to /profile/, label “My account”. Defaults for guest-side: link to /register, label “Start Learning Free”. Footer mode swaps the two-link guest pair (Register Free + Login) for a two-link member pair (My account + Log out, with the logout URL going through ?ep_membership_logout=1). All three modes accept overrides via member_text, guest_text, member_href, guest_href, and class.

Step 4

5. Patching the plugin and the theme instances

The plugin patch was applied via a small idempotent PHP script. It backed up plugin.php with a timestamped suffix, registered 'member-nav' => 'render_member_nav' in the shortcodes() array, and inserted the render_member_nav method just before render_login_gate. After writing, the script ran php -l on the result and would have rolled back automatically on a syntax error. The lint passed cleanly.

The theme patch worked on the JSON value of the S2L_Theme_instances option in pm_options. A second PHP script decoded the JSON, walked every string node in the tree, replaced three exact substrings, re-encoded with JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, and wrote it back inside one statement. Three replacements, one row affected:

1 × <li><a href="/register" class="nav-cta">Start Learning Free</a></li>
            →  [member-nav mode=cta]
1 × <li><a href="/register" style="color: var(--red); …">Start Learning Free →</a></li>
            →  [member-nav mode=mobile]
1 × <li><a href="/register">Register Free</a></li><li><a href="/login">Login</a></li>
            →  [member-nav mode=footer]

No nginx reload, no CSS recompile. PageMotor reads plugin code and theme instances on every request.

Step 4b

6. Bonus fix: missing braces on the profile page

While reading render_enrolled_courses for context, an unrelated bug was visible: an if with no curly braces, controlling only the first line of what should have been its body.

if (empty($enrolments))
    $redirect = $this->get_setting('registration_redirect', '/');
    return '<div class="profile-section">…You have not enrolled…</div>';

The return was unconditional. Every visit to /profile/, regardless of how many courses the user was enrolled in, returned the “You have not enrolled in any courses yet” message. The code further down that loops through enrolments and renders progress bars was unreachable. PHP also logged hundreds of Undefined variable $redirect warnings on every profile view, which is what first surfaced the line in the error log.

The patch script wrapped the if body in braces in the same pass. Lint OK.

Step 5

7. End-to-end verification

Two curl runs against the live site, one as guest and one with a freshly-registered cookie:

  • Guest top CTA → Start Learning Free/register  
  • Guest mobile CTA → same  
  • Guest footer → Register Free + Login  
  • Logged-in top CTA → My account/profile/  
  • Logged-in mobile CTA → My account →/profile/  
  • Logged-in footer → My account + Log out (logout URL https://s2l.online/?ep_membership_logout=1)  
  • Logged-in /sars-efiling/ → renders, Start Course button targets /watch/?course=sars-efiling&lesson=1  
  • Logged-in /watch/?course=sars-efiling&lesson=1 → “You need to enrol in this course first” with the data-course="9" Enrol Free button (correct branch for the auth-passed, not-enrolled state)  
  • /profile/ → renders “Your account” and the “Your Courses” section header (which would never have appeared before the braces fix)  
  • No new entries in /var/www/s2l/php-errors.log after the patch  
OutcomeThe first thing a freshly-registered user sees on the home page now reflects their state. The button name and its destination both change the moment registration completes. The reported “login loop” cannot recur because the loop never existed — the perception of one was an artefact of the static nav.
Later that day

9. Hiding two language pages until their courses are ready

HasmukhI want to hide some of the languages from view on my site … hide ndebele and tshivenda

Two of the eleven language pages on s2l.online — isiNdebele and Tshivenda — needed to come off public view because the underlying course content was not yet ready. The decision was “hide”, not “delete” — both pages had to be restorable in seconds.

A grep across pm_options showed that the two language names lived in four distinct places, all inside the S2L_Theme_instances JSON blob: the rotating welcome banner at the top of the home page (Hero_Content), the row of language tabs above the courses grid (Courses_Content), the “Courses in your language” cards (Languages_Content), and the Languages column of the footer (Footer_Content). Plus the two landing pages themselves at /ndebele and /tshivenda in pm_content.

The edit was done in Python end-to-end: pull the option value, parse JSON, target each of the four HTML_Box entries by key, do a structural removal in each, then push the whole option back via a parameterised PHP UPDATE. Trailing-comma cleanup on the rotating-banner array. The intro copy on the languages section also dropped from “all 10 South African languages” to “8 South African languages, with more South African languages on the way” so it matched the visible cards. Both landing pages were switched to status='draft'.

A first pass on the Languages section left a stray </div> after the last remaining card — the wrapper-close pattern needed to swallow the card’s own closing div as well. A targeted string replace cleaned that up before the value was pushed back to the database. Final structural counts after the edit, sanity-checked locally before the write:

language-cards in Languages_Content: 9
lang-tabs in Courses_Content:        9
language links in Footer_Content:    9
references to ndebele/tshivenda:     0

Verification on the live origin: GET / returned 200 with zero references to either hidden language name in the served HTML; GET /ndebele and GET /tshivenda both returned 302, redirecting away from the now-draft pages. The remaining nine languages render normally in the rotating banner, the courses tabs, the language cards, and the footer.

OutcomeTwo languages quietly disappear from the visible site without breaking layout, leaving room for them to return the moment the underlying courses are ready. Backups saved on the VPS for rollback: /tmp/lang_pages_backup_*.sql (the two affected page rows), /tmp/theme_before_lang_hide.json (full pre-change theme option), and /tmp/theme_after_lang_hide.json (the new value).
Open

8. Still open

One known frictional step remains: the Enrol Free button on /watch/ for free courses is a JavaScript-driven AJAX call, not a hard link. A user with a slow connection or a JS hiccup can click it and see no visible response. The cleaner path is auto-enrolment for free courses on the first visit to /watch/?course=… — turn the explicit Enrol click into an implicit server-side step. That is one click and one mental model fewer between “I want to learn” and “the lesson is playing.” Tagged for a future session.

Backups left in place for rollback:

  • /var/www/s2l/user-content/plugins/ep-membership/plugin.php.bak.20260426-133725
  • /tmp/S2L_Theme_instances.bak.20260426-133755.json